mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 21:10:38 +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",
|
||||
"NUMR",
|
||||
"Opencorpora",
|
||||
"passwordreset",
|
||||
"perfectivity",
|
||||
"PNCT",
|
||||
"ponomarev",
|
||||
|
|
7
TODO.txt
7
TODO.txt
|
@ -9,11 +9,14 @@ For more specific TODOs see comments in code
|
|||
- блок организации библиотеки моделей
|
||||
- проектный модуль?
|
||||
- обратная связь - система баг репортов
|
||||
- система обработки ошибок backend
|
||||
|
||||
[Tech]
|
||||
- multilevel modals / rework modal system
|
||||
- rewrite custom password-reset
|
||||
|
||||
[deployment]
|
||||
- logs collection
|
||||
- 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
|
||||
db_password:
|
||||
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:
|
||||
frontend:
|
||||
|
@ -28,7 +34,7 @@ services:
|
|||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
build:
|
||||
build:
|
||||
context: ./rsconcept/frontend
|
||||
args:
|
||||
BUILD_TYPE: production
|
||||
|
@ -36,7 +42,6 @@ services:
|
|||
- 3000
|
||||
command: serve -s /home/node -l 3000
|
||||
|
||||
|
||||
backend:
|
||||
container_name: portal-backend
|
||||
restart: always
|
||||
|
@ -45,20 +50,24 @@ services:
|
|||
secrets:
|
||||
- db_password
|
||||
- django_key
|
||||
- email_host
|
||||
- email_user
|
||||
- email_password
|
||||
build:
|
||||
context: ./rsconcept/backend
|
||||
env_file: ./rsconcept/backend/.env.prod
|
||||
environment:
|
||||
SECRET_KEY: /run/secrets/django_key
|
||||
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:
|
||||
- 8000
|
||||
volumes:
|
||||
- django_static_volume:/home/app/web/static
|
||||
- django_media_volume:/home/app/web/media
|
||||
command:
|
||||
gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000
|
||||
|
||||
command: gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000
|
||||
|
||||
postgresql-db:
|
||||
container_name: portal-db
|
||||
|
@ -72,7 +81,6 @@ services:
|
|||
volumes:
|
||||
- postgres_volume:/var/lib/postgresql/data
|
||||
|
||||
|
||||
certbot:
|
||||
container_name: portal-certbot
|
||||
restart: no
|
||||
|
@ -81,7 +89,6 @@ services:
|
|||
- cerbot_www_volume:/var/www/certbot/:rw
|
||||
- cerbot_conf_volume:/etc/letsencrypt/:rw
|
||||
|
||||
|
||||
nginx:
|
||||
container_name: portal-router
|
||||
restart: always
|
||||
|
@ -94,7 +101,7 @@ services:
|
|||
- 443:443
|
||||
depends_on:
|
||||
- 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:
|
||||
- django_static_volume:/var/www/static
|
||||
- django_media_volume:/var/www/media
|
||||
|
|
|
@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
|
|||
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
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_NAME=portal-db
|
||||
|
|
|
@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
|
|||
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
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_NAME=portal-db
|
||||
|
|
|
@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
|
|||
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
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_NAME=portal-db
|
||||
|
|
|
@ -6,3 +6,6 @@ class UsersConfig(AppConfig):
|
|||
''' Application config. '''
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
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)
|
||||
|
||||
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
''' Serializer: Change password. '''
|
||||
email = serializers.EmailField(required=True)
|
||||
|
||||
|
||||
class SignupSerializer(serializers.ModelSerializer):
|
||||
''' Serializer: Create user profile. '''
|
||||
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')
|
||||
|
||||
def test_edit_profile(self):
|
||||
newmail = 'newmail@gmail.com'
|
||||
data = {'email': newmail}
|
||||
new_mail = 'newmail@gmail.com'
|
||||
data = {'email': new_mail}
|
||||
response = self.client.patch(
|
||||
'/users/api/profile',
|
||||
data=data, format='json'
|
||||
|
@ -112,7 +112,7 @@ class TestUserUserProfileAPIView(APITestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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):
|
||||
newpassword = 'pw2'
|
||||
|
@ -150,6 +150,25 @@ class TestUserUserProfileAPIView(APITestCase):
|
|||
self.assertTrue(self.client.login(username=self.user.username, password=newpassword))
|
||||
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):
|
||||
def setUp(self):
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
''' Routing: User profile and Authorization. '''
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
from . import views
|
||||
from django_rest_passwordreset.views import reset_password_confirm, \
|
||||
reset_password_request_token, \
|
||||
reset_password_validate_token
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
@ -11,4 +14,8 @@ urlpatterns = [
|
|||
path('api/login', views.LoginAPIView.as_view()),
|
||||
path('api/logout', views.LogoutAPIView.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(';')
|
||||
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 = [
|
||||
'django.contrib.admin',
|
||||
|
@ -40,6 +52,7 @@ INSTALLED_APPS = [
|
|||
|
||||
'django_filters',
|
||||
'rest_framework',
|
||||
'django_rest_passwordreset',
|
||||
'corsheaders',
|
||||
|
||||
'apps.users',
|
||||
|
|
|
@ -9,6 +9,7 @@ pymorphy2==0.9.1
|
|||
pymorphy2-dicts-ru==2.4.417127.4579844
|
||||
pymorphy2-dicts-uk==2.4.1.1.1460299261
|
||||
razdel==0.5.0
|
||||
django-rest-passwordreset==1.4.0
|
||||
|
||||
psycopg2-binary==2.9.9
|
||||
gunicorn==21.2.0
|
|
@ -14,6 +14,7 @@ mypy
|
|||
pylint
|
||||
coverage
|
||||
djangorestframework-stubs[compatible-mypy]
|
||||
django-rest-passwordreset
|
||||
|
||||
psycopg2-binary
|
||||
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 ManualsPage from '@/pages/ManualsPage';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
import PasswordChangePage from '@/pages/PasswordChangePage';
|
||||
import RegisterPage from '@/pages/RegisterPage';
|
||||
import RestorePasswordPage from '@/pages/RestorePasswordPage';
|
||||
import RSFormPage from '@/pages/RSFormPage';
|
||||
|
@ -35,6 +36,10 @@ export const Router = createBrowserRouter([
|
|||
path: 'restore-password',
|
||||
element: <RestorePasswordPage />
|
||||
},
|
||||
{
|
||||
path: 'password-change',
|
||||
element: <PasswordChangePage />
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: <UserProfilePage />
|
||||
|
|
|
@ -4,13 +4,23 @@ import { createContext, useCallback, useContext, useLayoutEffect, useState } fro
|
|||
|
||||
import { type ErrorData } from '@/components/InfoError';
|
||||
import useLocalStorage from '@/hooks/useLocalStorage';
|
||||
import { IUserLoginData } from '@/models/library';
|
||||
import { IPasswordTokenData, IRequestPasswordData, IResetPasswordData, IUserLoginData } from '@/models/library';
|
||||
import { ICurrentUser } from '@/models/library';
|
||||
import { IUserSignupData } from '@/models/library';
|
||||
import { IUserProfile } from '@/models/library';
|
||||
import { IUserInfo } 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';
|
||||
|
||||
|
@ -20,6 +30,9 @@ interface IAuthContext {
|
|||
logout: (callback?: DataCallback) => void;
|
||||
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => 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;
|
||||
error: ErrorData;
|
||||
setError: (error: ErrorData) => void;
|
||||
|
@ -118,12 +131,77 @@ export const AuthState = ({ children }: AuthStateProps) => {
|
|||
[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(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
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}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
|
|
@ -29,6 +29,24 @@ export interface IUserLoginData extends Pick<IUser, 'username'> {
|
|||
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.
|
||||
*/
|
||||
|
|
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 InfoError, { ErrorData } from '@/components/InfoError';
|
||||
import SubmitButton from '@/components/ui/SubmitButton';
|
||||
import TextInput from '@/components/ui/TextInput';
|
||||
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() {
|
||||
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 (
|
||||
<AnimateFade className='py-3'>
|
||||
<p>Автоматическое восстановление пароля не доступно.</p>
|
||||
<p>
|
||||
Возможно восстановление пароля через обращение на <TextURL href={urls.mail_portal} text='portal@acconcept.ru' />
|
||||
</p>
|
||||
<AnimateFade>
|
||||
{!isCompleted ? (
|
||||
<form className={clsx('w-[24rem]', 'px-6 mt-3', classnames.flex_col)} onSubmit={handleSubmit}>
|
||||
<TextInput
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@ import {
|
|||
ICurrentUser,
|
||||
ILibraryItem,
|
||||
ILibraryUpdateData,
|
||||
IPasswordTokenData,
|
||||
IRequestPasswordData,
|
||||
IResetPasswordData as IResetPasswordData,
|
||||
IUserInfo,
|
||||
IUserLoginData,
|
||||
IUserProfile,
|
||||
|
@ -136,12 +139,36 @@ export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfil
|
|||
|
||||
export function patchPassword(request: FrontPush<IUserUpdatePassword>) {
|
||||
AxiosPatch({
|
||||
title: 'Update Password',
|
||||
title: 'Update password',
|
||||
endpoint: '/users/api/change-password',
|
||||
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[]>) {
|
||||
AxiosGet({
|
||||
title: 'Active users list',
|
||||
|
|
Loading…
Reference in New Issue
Block a user