mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-27 05:20:36 +03:00
Setup password recovery through email
This commit is contained in:
parent
76b5456162
commit
657f4fe11c
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -85,6 +85,7 @@
|
||||||
"NPRO",
|
"NPRO",
|
||||||
"NUMR",
|
"NUMR",
|
||||||
"Opencorpora",
|
"Opencorpora",
|
||||||
|
"passwordreset",
|
||||||
"perfectivity",
|
"perfectivity",
|
||||||
"PNCT",
|
"PNCT",
|
||||||
"ponomarev",
|
"ponomarev",
|
||||||
|
|
7
TODO.txt
7
TODO.txt
|
@ -9,11 +9,14 @@ For more specific TODOs see comments in code
|
||||||
- блок организации библиотеки моделей
|
- блок организации библиотеки моделей
|
||||||
- проектный модуль?
|
- проектный модуль?
|
||||||
- обратная связь - система баг репортов
|
- обратная связь - система баг репортов
|
||||||
- система обработки ошибок backend
|
|
||||||
|
|
||||||
[Tech]
|
[Tech]
|
||||||
- multilevel modals / rework modal system
|
- rewrite custom password-reset
|
||||||
|
|
||||||
[deployment]
|
[deployment]
|
||||||
- logs collection
|
- logs collection
|
||||||
- status dashboard for servers
|
- status dashboard for servers
|
||||||
|
|
||||||
|
[security]
|
||||||
|
- password-reset leaks info of email being used
|
||||||
|
- do not use schemaID for access (prevent enumerating IDs access)
|
|
@ -21,6 +21,12 @@ secrets:
|
||||||
file: ./secrets/django_key.txt
|
file: ./secrets/django_key.txt
|
||||||
db_password:
|
db_password:
|
||||||
file: ./secrets/db_password.txt
|
file: ./secrets/db_password.txt
|
||||||
|
email_host:
|
||||||
|
file: ./secrets/email_host.txt
|
||||||
|
email_user:
|
||||||
|
file: ./secrets/email_user.txt
|
||||||
|
email_password:
|
||||||
|
file: ./secrets/email_password.txt
|
||||||
|
|
||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
|
@ -36,7 +42,6 @@ services:
|
||||||
- 3000
|
- 3000
|
||||||
command: serve -s /home/node -l 3000
|
command: serve -s /home/node -l 3000
|
||||||
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
container_name: portal-backend
|
container_name: portal-backend
|
||||||
restart: always
|
restart: always
|
||||||
|
@ -45,20 +50,24 @@ services:
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
- django_key
|
- django_key
|
||||||
|
- email_host
|
||||||
|
- email_user
|
||||||
|
- email_password
|
||||||
build:
|
build:
|
||||||
context: ./rsconcept/backend
|
context: ./rsconcept/backend
|
||||||
env_file: ./rsconcept/backend/.env.prod
|
env_file: ./rsconcept/backend/.env.prod
|
||||||
environment:
|
environment:
|
||||||
SECRET_KEY: /run/secrets/django_key
|
SECRET_KEY: /run/secrets/django_key
|
||||||
DB_PASSWORD: /run/secrets/db_password
|
DB_PASSWORD: /run/secrets/db_password
|
||||||
|
EMAIL_HOST: /run/secrets/email_host
|
||||||
|
EMAIL_HOST_USER: /run/secrets/email_user
|
||||||
|
EMAIL_HOST_PASSWORD: /run/secrets/email_password
|
||||||
expose:
|
expose:
|
||||||
- 8000
|
- 8000
|
||||||
volumes:
|
volumes:
|
||||||
- django_static_volume:/home/app/web/static
|
- django_static_volume:/home/app/web/static
|
||||||
- django_media_volume:/home/app/web/media
|
- django_media_volume:/home/app/web/media
|
||||||
command:
|
command: gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000
|
||||||
gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000
|
|
||||||
|
|
||||||
|
|
||||||
postgresql-db:
|
postgresql-db:
|
||||||
container_name: portal-db
|
container_name: portal-db
|
||||||
|
@ -72,7 +81,6 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_volume:/var/lib/postgresql/data
|
- postgres_volume:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
container_name: portal-certbot
|
container_name: portal-certbot
|
||||||
restart: no
|
restart: no
|
||||||
|
@ -81,7 +89,6 @@ services:
|
||||||
- cerbot_www_volume:/var/www/certbot/:rw
|
- cerbot_www_volume:/var/www/certbot/:rw
|
||||||
- cerbot_conf_volume:/etc/letsencrypt/:rw
|
- cerbot_conf_volume:/etc/letsencrypt/:rw
|
||||||
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
container_name: portal-router
|
container_name: portal-router
|
||||||
restart: always
|
restart: always
|
||||||
|
@ -94,7 +101,7 @@ services:
|
||||||
- 443:443
|
- 443:443
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''
|
||||||
volumes:
|
volumes:
|
||||||
- django_static_volume:/var/www/static
|
- django_static_volume:/var/www/static
|
||||||
- django_media_volume:/var/www/media
|
- django_media_volume:/var/www/media
|
||||||
|
|
|
@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
|
||||||
MEDIA_ROOT=/home/app/web/media
|
MEDIA_ROOT=/home/app/web/media
|
||||||
|
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_PORT=1025
|
||||||
|
EMAIL_HOST_USER=False
|
||||||
|
EMAIL_HOST_PASSWORD=False
|
||||||
|
EMAIL_SSL=False
|
||||||
|
EMAIL_TLS=False
|
||||||
|
|
||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||||
DB_NAME=portal-db
|
DB_NAME=portal-db
|
||||||
|
|
|
@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
|
||||||
MEDIA_ROOT=/home/app/web/media
|
MEDIA_ROOT=/home/app/web/media
|
||||||
|
|
||||||
|
|
||||||
|
# Email
|
||||||
|
# EMAIL_HOST=
|
||||||
|
# EMAIL_HOST_USER=
|
||||||
|
# EMAIL_HOST_PASSWORD=
|
||||||
|
EMAIL_PORT=443
|
||||||
|
EMAIL_SSL=True
|
||||||
|
EMAIL_TLS=False
|
||||||
|
|
||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||||
DB_NAME=portal-db
|
DB_NAME=portal-db
|
||||||
|
|
|
@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
|
||||||
MEDIA_ROOT=/home/app/web/media
|
MEDIA_ROOT=/home/app/web/media
|
||||||
|
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_PORT=1025
|
||||||
|
EMAIL_HOST_USER=False
|
||||||
|
EMAIL_HOST_PASSWORD=False
|
||||||
|
EMAIL_SSL=False
|
||||||
|
EMAIL_TLS=False
|
||||||
|
|
||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||||
DB_NAME=portal-db
|
DB_NAME=portal-db
|
||||||
|
|
|
@ -6,3 +6,6 @@ class UsersConfig(AppConfig):
|
||||||
''' Application config. '''
|
''' Application config. '''
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'apps.users'
|
name = 'apps.users'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import apps.users.signals # pylint: disable=unused-import,import-outside-toplevel
|
||||||
|
|
|
@ -105,11 +105,6 @@ class ChangePasswordSerializer(serializers.Serializer):
|
||||||
new_password = serializers.CharField(required=True)
|
new_password = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordSerializer(serializers.Serializer):
|
|
||||||
''' Serializer: Change password. '''
|
|
||||||
email = serializers.EmailField(required=True)
|
|
||||||
|
|
||||||
|
|
||||||
class SignupSerializer(serializers.ModelSerializer):
|
class SignupSerializer(serializers.ModelSerializer):
|
||||||
''' Serializer: Create user profile. '''
|
''' Serializer: Create user profile. '''
|
||||||
id = serializers.IntegerField(read_only=True)
|
id = serializers.IntegerField(read_only=True)
|
||||||
|
|
37
rsconcept/backend/apps/users/signals.py
Normal file
37
rsconcept/backend/apps/users/signals.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
''' Signals: user events. '''
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
|
||||||
|
from django_rest_passwordreset.signals import reset_password_token_created # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
_EMAIL_NOREPLY = 'noreply.portal@acconcept.ru'
|
||||||
|
_EMAIL_SUBJECT = 'Восстановление пароля КонцептПортал'
|
||||||
|
_EMAIL_TEMPLATE = 'password_reset_email.html'
|
||||||
|
_RECOVERY_URL = 'https://portal.acconcept.ru/password-change'
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(reset_password_token_created)
|
||||||
|
def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs):
|
||||||
|
'''
|
||||||
|
Handles password reset tokens
|
||||||
|
When a token is created, an e-mail needs to be sent to the user
|
||||||
|
'''
|
||||||
|
context = {
|
||||||
|
'current_user': reset_password_token.user,
|
||||||
|
'username': reset_password_token.user.username,
|
||||||
|
'first_name': reset_password_token.user.first_name,
|
||||||
|
'email': reset_password_token.user.email,
|
||||||
|
'reset_password_url': f'{_RECOVERY_URL}?token={reset_password_token.key}'
|
||||||
|
}
|
||||||
|
email_html_message = render_to_string(_EMAIL_TEMPLATE, context)
|
||||||
|
email_plaintext_message = strip_tags(email_html_message)
|
||||||
|
send_mail(
|
||||||
|
subject=_EMAIL_SUBJECT,
|
||||||
|
message=email_plaintext_message,
|
||||||
|
from_email=_EMAIL_NOREPLY,
|
||||||
|
recipient_list=[context['email']],
|
||||||
|
html_message=email_html_message
|
||||||
|
)
|
|
@ -97,8 +97,8 @@ class TestUserUserProfileAPIView(APITestCase):
|
||||||
self.assertEqual(response.data['last_name'], 'lastName')
|
self.assertEqual(response.data['last_name'], 'lastName')
|
||||||
|
|
||||||
def test_edit_profile(self):
|
def test_edit_profile(self):
|
||||||
newmail = 'newmail@gmail.com'
|
new_mail = 'newmail@gmail.com'
|
||||||
data = {'email': newmail}
|
data = {'email': new_mail}
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
'/users/api/profile',
|
'/users/api/profile',
|
||||||
data=data, format='json'
|
data=data, format='json'
|
||||||
|
@ -112,7 +112,7 @@ class TestUserUserProfileAPIView(APITestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data['username'], self.username)
|
self.assertEqual(response.data['username'], self.username)
|
||||||
self.assertEqual(response.data['email'], newmail)
|
self.assertEqual(response.data['email'], new_mail)
|
||||||
|
|
||||||
def test_change_password(self):
|
def test_change_password(self):
|
||||||
newpassword = 'pw2'
|
newpassword = 'pw2'
|
||||||
|
@ -150,6 +150,25 @@ class TestUserUserProfileAPIView(APITestCase):
|
||||||
self.assertTrue(self.client.login(username=self.user.username, password=newpassword))
|
self.assertTrue(self.client.login(username=self.user.username, password=newpassword))
|
||||||
self.assertFalse(self.client.login(username=self.user.username, password=self.password))
|
self.assertFalse(self.client.login(username=self.user.username, password=self.password))
|
||||||
|
|
||||||
|
def test_password_reset_request(self):
|
||||||
|
data = {
|
||||||
|
'email': 'invalid@gmail.com'
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
'/users/api/password-reset',
|
||||||
|
data=data, format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'email': self.email
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
'/users/api/password-reset',
|
||||||
|
data=data, format='json'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class TestSignupAPIView(APITestCase):
|
class TestSignupAPIView(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
''' Routing: User profile and Authorization. '''
|
''' Routing: User profile and Authorization. '''
|
||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
from . import views
|
from . import views
|
||||||
|
from django_rest_passwordreset.views import reset_password_confirm, \
|
||||||
|
reset_password_request_token, \
|
||||||
|
reset_password_validate_token
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -11,4 +14,8 @@ urlpatterns = [
|
||||||
path('api/login', views.LoginAPIView.as_view()),
|
path('api/login', views.LoginAPIView.as_view()),
|
||||||
path('api/logout', views.LogoutAPIView.as_view()),
|
path('api/logout', views.LogoutAPIView.as_view()),
|
||||||
path('api/change-password', views.UpdatePassword.as_view()),
|
path('api/change-password', views.UpdatePassword.as_view()),
|
||||||
|
# django_rest_passwordreset APIs see https://pypi.org/project/django-rest-passwordreset/
|
||||||
|
path('api/password-reset', reset_password_request_token, name='reset-password-request'),
|
||||||
|
path('api/password-reset/validate', reset_password_validate_token, name='reset-password-validate'),
|
||||||
|
path('api/password-reset/confirm', reset_password_confirm, name='reset-password-confirm')
|
||||||
]
|
]
|
||||||
|
|
|
@ -29,6 +29,18 @@ DEBUG = os.environ.get('DEBUG', True) in [True, 'True', '1']
|
||||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(';')
|
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(';')
|
||||||
INTERNAL_IPS = ['127.0.0.1'] if DEBUG else []
|
INTERNAL_IPS = ['127.0.0.1'] if DEBUG else []
|
||||||
|
|
||||||
|
# MAIL SETUP
|
||||||
|
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'localhost')
|
||||||
|
EMAIL_PORT = os.environ.get('EMAIL_PORT', '1025')
|
||||||
|
EMAIL_USE_SSL = os.environ.get('EMAIL_SSL', False)
|
||||||
|
EMAIL_USE_TLS = os.environ.get('EMAIL_TLS', False)
|
||||||
|
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
||||||
|
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||||
|
EMAIL_BACKEND = \
|
||||||
|
'django.core.mail.backends.smtp.EmailBackend' \
|
||||||
|
if EMAIL_HOST != '' else \
|
||||||
|
'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
|
@ -40,6 +52,7 @@ INSTALLED_APPS = [
|
||||||
|
|
||||||
'django_filters',
|
'django_filters',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
|
'django_rest_passwordreset',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
|
|
||||||
'apps.users',
|
'apps.users',
|
||||||
|
|
|
@ -9,6 +9,7 @@ pymorphy2==0.9.1
|
||||||
pymorphy2-dicts-ru==2.4.417127.4579844
|
pymorphy2-dicts-ru==2.4.417127.4579844
|
||||||
pymorphy2-dicts-uk==2.4.1.1.1460299261
|
pymorphy2-dicts-uk==2.4.1.1.1460299261
|
||||||
razdel==0.5.0
|
razdel==0.5.0
|
||||||
|
django-rest-passwordreset==1.4.0
|
||||||
|
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
|
@ -14,6 +14,7 @@ mypy
|
||||||
pylint
|
pylint
|
||||||
coverage
|
coverage
|
||||||
djangorestframework-stubs[compatible-mypy]
|
djangorestframework-stubs[compatible-mypy]
|
||||||
|
django-rest-passwordreset
|
||||||
|
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
gunicorn
|
gunicorn
|
20
rsconcept/backend/templates/password_reset_email.html
Normal file
20
rsconcept/backend/templates/password_reset_email.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Восстановление пароля КонцептПортал</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Здравствуйте, {{first_name}}</p>
|
||||||
|
<p>
|
||||||
|
Мы получили запрос на восстановление пароля для пользователя {{username}},
|
||||||
|
привязанного к данному email. Если Вы отправили указанный запрос, то
|
||||||
|
перейдите по ссылке для задания нового пароля:
|
||||||
|
</p>
|
||||||
|
<p><a href="{{ reset_password_url }}">Сбросить пароль</a></p>
|
||||||
|
<p>
|
||||||
|
Если Вы не отправляли запрос, то можете игнорировать данное сообщение.
|
||||||
|
</p>
|
||||||
|
<p>С уважением,</p>
|
||||||
|
<p>Команда КонцептПортал</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -6,6 +6,7 @@ import LibraryPage from '@/pages/LibraryPage';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
import ManualsPage from '@/pages/ManualsPage';
|
import ManualsPage from '@/pages/ManualsPage';
|
||||||
import NotFoundPage from '@/pages/NotFoundPage';
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
|
import PasswordChangePage from '@/pages/PasswordChangePage';
|
||||||
import RegisterPage from '@/pages/RegisterPage';
|
import RegisterPage from '@/pages/RegisterPage';
|
||||||
import RestorePasswordPage from '@/pages/RestorePasswordPage';
|
import RestorePasswordPage from '@/pages/RestorePasswordPage';
|
||||||
import RSFormPage from '@/pages/RSFormPage';
|
import RSFormPage from '@/pages/RSFormPage';
|
||||||
|
@ -35,6 +36,10 @@ export const Router = createBrowserRouter([
|
||||||
path: 'restore-password',
|
path: 'restore-password',
|
||||||
element: <RestorePasswordPage />
|
element: <RestorePasswordPage />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'password-change',
|
||||||
|
element: <PasswordChangePage />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
element: <UserProfilePage />
|
element: <UserProfilePage />
|
||||||
|
|
|
@ -4,13 +4,23 @@ import { createContext, useCallback, useContext, useLayoutEffect, useState } fro
|
||||||
|
|
||||||
import { type ErrorData } from '@/components/InfoError';
|
import { type ErrorData } from '@/components/InfoError';
|
||||||
import useLocalStorage from '@/hooks/useLocalStorage';
|
import useLocalStorage from '@/hooks/useLocalStorage';
|
||||||
import { IUserLoginData } from '@/models/library';
|
import { IPasswordTokenData, IRequestPasswordData, IResetPasswordData, IUserLoginData } from '@/models/library';
|
||||||
import { ICurrentUser } from '@/models/library';
|
import { ICurrentUser } from '@/models/library';
|
||||||
import { IUserSignupData } from '@/models/library';
|
import { IUserSignupData } from '@/models/library';
|
||||||
import { IUserProfile } from '@/models/library';
|
import { IUserProfile } from '@/models/library';
|
||||||
import { IUserInfo } from '@/models/library';
|
import { IUserInfo } from '@/models/library';
|
||||||
import { IUserUpdatePassword } from '@/models/library';
|
import { IUserUpdatePassword } from '@/models/library';
|
||||||
import { type DataCallback, getAuth, patchPassword, postLogin, postLogout, postSignup } from '@/utils/backendAPI';
|
import {
|
||||||
|
type DataCallback,
|
||||||
|
getAuth,
|
||||||
|
patchPassword,
|
||||||
|
postLogin,
|
||||||
|
postLogout,
|
||||||
|
postRequestPasswordReset,
|
||||||
|
postResetPassword,
|
||||||
|
postSignup,
|
||||||
|
postValidatePasswordToken
|
||||||
|
} from '@/utils/backendAPI';
|
||||||
|
|
||||||
import { useUsers } from './UsersContext';
|
import { useUsers } from './UsersContext';
|
||||||
|
|
||||||
|
@ -20,6 +30,9 @@ interface IAuthContext {
|
||||||
logout: (callback?: DataCallback) => void;
|
logout: (callback?: DataCallback) => void;
|
||||||
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void;
|
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void;
|
||||||
updatePassword: (data: IUserUpdatePassword, callback?: () => void) => void;
|
updatePassword: (data: IUserUpdatePassword, callback?: () => void) => void;
|
||||||
|
requestPasswordReset: (data: IRequestPasswordData, callback?: () => void) => void;
|
||||||
|
validateToken: (data: IPasswordTokenData, callback?: () => void) => void;
|
||||||
|
resetPassword: (data: IResetPasswordData, callback?: () => void) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: ErrorData;
|
error: ErrorData;
|
||||||
setError: (error: ErrorData) => void;
|
setError: (error: ErrorData) => void;
|
||||||
|
@ -118,12 +131,77 @@ export const AuthState = ({ children }: AuthStateProps) => {
|
||||||
[reload]
|
[reload]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestPasswordReset = useCallback(
|
||||||
|
(data: IRequestPasswordData, callback?: () => void) => {
|
||||||
|
setError(undefined);
|
||||||
|
postRequestPasswordReset({
|
||||||
|
data: data,
|
||||||
|
showError: false,
|
||||||
|
setLoading: setLoading,
|
||||||
|
onError: setError,
|
||||||
|
onSuccess: () =>
|
||||||
|
reload(() => {
|
||||||
|
if (callback) callback();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[reload]
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateToken = useCallback(
|
||||||
|
(data: IPasswordTokenData, callback?: () => void) => {
|
||||||
|
setError(undefined);
|
||||||
|
postValidatePasswordToken({
|
||||||
|
data: data,
|
||||||
|
showError: false,
|
||||||
|
setLoading: setLoading,
|
||||||
|
onError: setError,
|
||||||
|
onSuccess: () =>
|
||||||
|
reload(() => {
|
||||||
|
if (callback) callback();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[reload]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetPassword = useCallback(
|
||||||
|
(data: IResetPasswordData, callback?: () => void) => {
|
||||||
|
setError(undefined);
|
||||||
|
postResetPassword({
|
||||||
|
data: data,
|
||||||
|
showError: false,
|
||||||
|
setLoading: setLoading,
|
||||||
|
onError: setError,
|
||||||
|
onSuccess: () =>
|
||||||
|
reload(() => {
|
||||||
|
if (callback) callback();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[reload]
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout, signup, loading, error, setError, updatePassword }}>
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
signup,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
updatePassword,
|
||||||
|
requestPasswordReset,
|
||||||
|
validateToken,
|
||||||
|
resetPassword
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -29,6 +29,24 @@ export interface IUserLoginData extends Pick<IUser, 'username'> {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents password reset data.
|
||||||
|
*/
|
||||||
|
export interface IResetPasswordData {
|
||||||
|
password: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents password token data.
|
||||||
|
*/
|
||||||
|
export interface IPasswordTokenData extends Pick<IResetPasswordData, 'token'> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents password reset request data.
|
||||||
|
*/
|
||||||
|
export interface IRequestPasswordData extends Pick<IUser, 'email'> {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents signup data, used to create new users.
|
* Represents signup data, used to create new users.
|
||||||
*/
|
*/
|
||||||
|
|
117
rsconcept/frontend/src/pages/PasswordChangePage.tsx
Normal file
117
rsconcept/frontend/src/pages/PasswordChangePage.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import DataLoader from '@/components/DataLoader';
|
||||||
|
import InfoError, { ErrorData } from '@/components/InfoError';
|
||||||
|
import SubmitButton from '@/components/ui/SubmitButton';
|
||||||
|
import TextInput from '@/components/ui/TextInput';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { useConceptNavigation } from '@/context/NavigationContext';
|
||||||
|
import useQueryStrings from '@/hooks/useQueryStrings';
|
||||||
|
import { IPasswordTokenData, IResetPasswordData } from '@/models/library';
|
||||||
|
import { classnames } from '@/utils/constants';
|
||||||
|
|
||||||
|
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
|
||||||
|
if (axios.isAxiosError(error) && error.response && error.response.status === 404) {
|
||||||
|
return <div className='mt-6 text-sm select-text clr-text-warning'>Данная ссылка не действительна</div>;
|
||||||
|
} else {
|
||||||
|
return <InfoError error={error} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordChangePage() {
|
||||||
|
const router = useConceptNavigation();
|
||||||
|
const token = useQueryStrings().get('token');
|
||||||
|
|
||||||
|
const { validateToken, resetPassword, loading, error, setError } = useAuth();
|
||||||
|
|
||||||
|
const [isTokenValid, setIsTokenValid] = useState(false);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [newPasswordRepeat, setNewPasswordRepeat] = useState('');
|
||||||
|
|
||||||
|
const passwordColor = useMemo(() => {
|
||||||
|
if (!!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat) {
|
||||||
|
return 'clr-warning';
|
||||||
|
} else {
|
||||||
|
return 'clr-input';
|
||||||
|
}
|
||||||
|
}, [newPassword, newPasswordRepeat]);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
return !!newPassword && !!newPasswordRepeat && newPassword === newPasswordRepeat;
|
||||||
|
}, [newPassword, newPasswordRepeat]);
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!loading) {
|
||||||
|
const data: IResetPasswordData = {
|
||||||
|
password: newPassword,
|
||||||
|
token: token!
|
||||||
|
};
|
||||||
|
resetPassword(data, () => {
|
||||||
|
router.replace('/');
|
||||||
|
router.push('/login');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(undefined);
|
||||||
|
}, [newPassword, newPasswordRepeat, setError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data: IPasswordTokenData = {
|
||||||
|
token: token ?? ''
|
||||||
|
};
|
||||||
|
validateToken(data, () => setIsTokenValid(true));
|
||||||
|
}, [token, validateToken]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ProcessError error={error} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DataLoader
|
||||||
|
id='password-change-page' //
|
||||||
|
isLoading={loading}
|
||||||
|
hasNoData={!isTokenValid}
|
||||||
|
>
|
||||||
|
<form className={clsx('w-[24rem]', 'px-6 mt-3', classnames.flex_col)} onSubmit={handleSubmit}>
|
||||||
|
<TextInput
|
||||||
|
id='new_password'
|
||||||
|
type='password'
|
||||||
|
allowEnter
|
||||||
|
label='Новый пароль'
|
||||||
|
colors={passwordColor}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={event => {
|
||||||
|
setNewPassword(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id='new_password_repeat'
|
||||||
|
type='password'
|
||||||
|
allowEnter
|
||||||
|
label='Повторите новый'
|
||||||
|
colors={passwordColor}
|
||||||
|
value={newPasswordRepeat}
|
||||||
|
onChange={event => {
|
||||||
|
setNewPasswordRepeat(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
text='Установить пароль'
|
||||||
|
className='self-center w-[12rem] mt-3'
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
/>
|
||||||
|
{error ? <ProcessError error={error} /> : null}
|
||||||
|
</form>
|
||||||
|
</DataLoader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PasswordChangePage;
|
|
@ -1,14 +1,78 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import AnimateFade from '@/components/AnimateFade';
|
import AnimateFade from '@/components/AnimateFade';
|
||||||
|
import InfoError, { ErrorData } from '@/components/InfoError';
|
||||||
|
import SubmitButton from '@/components/ui/SubmitButton';
|
||||||
|
import TextInput from '@/components/ui/TextInput';
|
||||||
import TextURL from '@/components/ui/TextURL';
|
import TextURL from '@/components/ui/TextURL';
|
||||||
import { urls } from '@/utils/constants';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { IRequestPasswordData } from '@/models/library';
|
||||||
|
import { classnames } from '@/utils/constants';
|
||||||
|
|
||||||
|
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
|
||||||
|
if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
|
||||||
|
return (
|
||||||
|
<div className='mt-6 text-sm select-text clr-text-warning'>
|
||||||
|
На Портале отсутствует пользователь с таким email.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <InfoError error={error} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function RestorePasswordPage() {
|
function RestorePasswordPage() {
|
||||||
|
const { requestPasswordReset, loading, error, setError } = useAuth();
|
||||||
|
|
||||||
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!loading) {
|
||||||
|
const data: IRequestPasswordData = {
|
||||||
|
email: email
|
||||||
|
};
|
||||||
|
requestPasswordReset(data, () => setIsCompleted(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(undefined);
|
||||||
|
}, [email, setError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimateFade className='py-3'>
|
<AnimateFade>
|
||||||
<p>Автоматическое восстановление пароля не доступно.</p>
|
{!isCompleted ? (
|
||||||
<p>
|
<form className={clsx('w-[24rem]', 'px-6 mt-3', classnames.flex_col)} onSubmit={handleSubmit}>
|
||||||
Возможно восстановление пароля через обращение на <TextURL href={urls.mail_portal} text='portal@acconcept.ru' />
|
<TextInput
|
||||||
</p>
|
id='email'
|
||||||
|
allowEnter
|
||||||
|
label='Электронная почта'
|
||||||
|
value={email}
|
||||||
|
onChange={event => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
text='Запросить пароль'
|
||||||
|
className='self-center w-[12rem] mt-3'
|
||||||
|
loading={loading}
|
||||||
|
disabled={!email}
|
||||||
|
/>
|
||||||
|
{error ? <ProcessError error={error} /> : null}
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{isCompleted ? (
|
||||||
|
<div className='flex flex-col items-center gap-1 mt-3'>
|
||||||
|
<p>На указанную почту отправлены инструкции по восстановлению пароля.</p>
|
||||||
|
<TextURL text='Войти в Портал' href='/login' />
|
||||||
|
<TextURL text='Начальная страница' href='/' />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</AnimateFade>
|
</AnimateFade>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,9 @@ import {
|
||||||
ICurrentUser,
|
ICurrentUser,
|
||||||
ILibraryItem,
|
ILibraryItem,
|
||||||
ILibraryUpdateData,
|
ILibraryUpdateData,
|
||||||
|
IPasswordTokenData,
|
||||||
|
IRequestPasswordData,
|
||||||
|
IResetPasswordData as IResetPasswordData,
|
||||||
IUserInfo,
|
IUserInfo,
|
||||||
IUserLoginData,
|
IUserLoginData,
|
||||||
IUserProfile,
|
IUserProfile,
|
||||||
|
@ -136,12 +139,36 @@ export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfil
|
||||||
|
|
||||||
export function patchPassword(request: FrontPush<IUserUpdatePassword>) {
|
export function patchPassword(request: FrontPush<IUserUpdatePassword>) {
|
||||||
AxiosPatch({
|
AxiosPatch({
|
||||||
title: 'Update Password',
|
title: 'Update password',
|
||||||
endpoint: '/users/api/change-password',
|
endpoint: '/users/api/change-password',
|
||||||
request: request
|
request: request
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function postRequestPasswordReset(request: FrontPush<IRequestPasswordData>) {
|
||||||
|
AxiosPost({
|
||||||
|
title: 'Request password reset',
|
||||||
|
endpoint: '/users/api/password-reset',
|
||||||
|
request: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postValidatePasswordToken(request: FrontPush<IPasswordTokenData>) {
|
||||||
|
AxiosPost({
|
||||||
|
title: 'Validate password token',
|
||||||
|
endpoint: '/users/api/password-reset/validate',
|
||||||
|
request: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postResetPassword(request: FrontPush<IResetPasswordData>) {
|
||||||
|
AxiosPost({
|
||||||
|
title: 'Reset password',
|
||||||
|
endpoint: '/users/api/password-reset/confirm',
|
||||||
|
request: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
|
export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
|
||||||
AxiosGet({
|
AxiosGet({
|
||||||
title: 'Active users list',
|
title: 'Active users list',
|
||||||
|
|
Loading…
Reference in New Issue
Block a user