diff --git a/.vscode/settings.json b/.vscode/settings.json index d08d75cd..12e2cfcf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -85,6 +85,7 @@ "NPRO", "NUMR", "Opencorpora", + "passwordreset", "perfectivity", "PNCT", "ponomarev", diff --git a/TODO.txt b/TODO.txt index 6b125c76..fc8450a8 100644 --- a/TODO.txt +++ b/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) \ No newline at end of file diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 6298f1a4..96a6037a 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -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 diff --git a/rsconcept/backend/.env.dev b/rsconcept/backend/.env.dev index 501f8a22..9269dc32 100644 --- a/rsconcept/backend/.env.dev +++ b/rsconcept/backend/.env.dev @@ -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 diff --git a/rsconcept/backend/.env.prod b/rsconcept/backend/.env.prod index 90cedabd..62432a1c 100644 --- a/rsconcept/backend/.env.prod +++ b/rsconcept/backend/.env.prod @@ -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 diff --git a/rsconcept/backend/.env.prod.local b/rsconcept/backend/.env.prod.local index da3f2e1e..84268d38 100644 --- a/rsconcept/backend/.env.prod.local +++ b/rsconcept/backend/.env.prod.local @@ -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 diff --git a/rsconcept/backend/apps/users/apps.py b/rsconcept/backend/apps/users/apps.py index 0376c6a9..61a6e7b5 100644 --- a/rsconcept/backend/apps/users/apps.py +++ b/rsconcept/backend/apps/users/apps.py @@ -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 diff --git a/rsconcept/backend/apps/users/serializers.py b/rsconcept/backend/apps/users/serializers.py index 7a48796c..a790e5ee 100644 --- a/rsconcept/backend/apps/users/serializers.py +++ b/rsconcept/backend/apps/users/serializers.py @@ -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) diff --git a/rsconcept/backend/apps/users/signals.py b/rsconcept/backend/apps/users/signals.py new file mode 100644 index 00000000..9154ecd5 --- /dev/null +++ b/rsconcept/backend/apps/users/signals.py @@ -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 + ) diff --git a/rsconcept/backend/apps/users/tests/t_views.py b/rsconcept/backend/apps/users/tests/t_views.py index cd7b554a..05270a2f 100644 --- a/rsconcept/backend/apps/users/tests/t_views.py +++ b/rsconcept/backend/apps/users/tests/t_views.py @@ -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): diff --git a/rsconcept/backend/apps/users/urls.py b/rsconcept/backend/apps/users/urls.py index 876e62ac..c37b3a06 100644 --- a/rsconcept/backend/apps/users/urls.py +++ b/rsconcept/backend/apps/users/urls.py @@ -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') ] diff --git a/rsconcept/backend/project/settings.py b/rsconcept/backend/project/settings.py index bb3ab66b..ee59965e 100644 --- a/rsconcept/backend/project/settings.py +++ b/rsconcept/backend/project/settings.py @@ -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', diff --git a/rsconcept/backend/requirements.txt b/rsconcept/backend/requirements.txt index 66898b6e..73999e10 100644 --- a/rsconcept/backend/requirements.txt +++ b/rsconcept/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/rsconcept/backend/requirements_dev.txt b/rsconcept/backend/requirements_dev.txt index 9c5e9a1c..d61f23d6 100644 --- a/rsconcept/backend/requirements_dev.txt +++ b/rsconcept/backend/requirements_dev.txt @@ -14,6 +14,7 @@ mypy pylint coverage djangorestframework-stubs[compatible-mypy] +django-rest-passwordreset psycopg2-binary gunicorn \ No newline at end of file diff --git a/rsconcept/backend/templates/password_reset_email.html b/rsconcept/backend/templates/password_reset_email.html new file mode 100644 index 00000000..8c610c84 --- /dev/null +++ b/rsconcept/backend/templates/password_reset_email.html @@ -0,0 +1,20 @@ + + + + Восстановление пароля КонцептПортал + + +

Здравствуйте, {{first_name}}

+

+ Мы получили запрос на восстановление пароля для пользователя {{username}}, + привязанного к данному email. Если Вы отправили указанный запрос, то + перейдите по ссылке для задания нового пароля: +

+

Сбросить пароль

+

+ Если Вы не отправляли запрос, то можете игнорировать данное сообщение. +

+

С уважением,

+

Команда КонцептПортал

+ + diff --git a/rsconcept/frontend/src/app/Router.tsx b/rsconcept/frontend/src/app/Router.tsx index 9bbb41c4..503aeefe 100644 --- a/rsconcept/frontend/src/app/Router.tsx +++ b/rsconcept/frontend/src/app/Router.tsx @@ -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: }, + { + path: 'password-change', + element: + }, { path: 'profile', element: diff --git a/rsconcept/frontend/src/context/AuthContext.tsx b/rsconcept/frontend/src/context/AuthContext.tsx index 542a84f7..c36db596 100644 --- a/rsconcept/frontend/src/context/AuthContext.tsx +++ b/rsconcept/frontend/src/context/AuthContext.tsx @@ -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) => 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 ( - + {children} ); diff --git a/rsconcept/frontend/src/models/library.ts b/rsconcept/frontend/src/models/library.ts index 178e31bc..25462d82 100644 --- a/rsconcept/frontend/src/models/library.ts +++ b/rsconcept/frontend/src/models/library.ts @@ -29,6 +29,24 @@ export interface IUserLoginData extends Pick { password: string; } +/** + * Represents password reset data. + */ +export interface IResetPasswordData { + password: string; + token: string; +} + +/** + * Represents password token data. + */ +export interface IPasswordTokenData extends Pick {} + +/** + * Represents password reset request data. + */ +export interface IRequestPasswordData extends Pick {} + /** * Represents signup data, used to create new users. */ diff --git a/rsconcept/frontend/src/pages/PasswordChangePage.tsx b/rsconcept/frontend/src/pages/PasswordChangePage.tsx new file mode 100644 index 00000000..638d8010 --- /dev/null +++ b/rsconcept/frontend/src/pages/PasswordChangePage.tsx @@ -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
Данная ссылка не действительна
; + } else { + return ; + } +} + +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) { + 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 ; + } + return ( + +
+ { + setNewPassword(event.target.value); + }} + /> + { + setNewPasswordRepeat(event.target.value); + }} + /> + + + {error ? : null} + +
+ ); +} + +export default PasswordChangePage; diff --git a/rsconcept/frontend/src/pages/RestorePasswordPage.tsx b/rsconcept/frontend/src/pages/RestorePasswordPage.tsx index b8dac7c2..348a1f40 100644 --- a/rsconcept/frontend/src/pages/RestorePasswordPage.tsx +++ b/rsconcept/frontend/src/pages/RestorePasswordPage.tsx @@ -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 ( +
+ На Портале отсутствует пользователь с таким email. +
+ ); + } else { + return ; + } +} function RestorePasswordPage() { + const { requestPasswordReset, loading, error, setError } = useAuth(); + + const [isCompleted, setIsCompleted] = useState(false); + const [email, setEmail] = useState(''); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!loading) { + const data: IRequestPasswordData = { + email: email + }; + requestPasswordReset(data, () => setIsCompleted(true)); + } + } + + useEffect(() => { + setError(undefined); + }, [email, setError]); + return ( - -

Автоматическое восстановление пароля не доступно.

-

- Возможно восстановление пароля через обращение на -

+ + {!isCompleted ? ( +
+ setEmail(event.target.value)} + /> + + + {error ? : null} + + ) : null} + {isCompleted ? ( +
+

На указанную почту отправлены инструкции по восстановлению пароля.

+ + +
+ ) : null}
); } diff --git a/rsconcept/frontend/src/utils/backendAPI.ts b/rsconcept/frontend/src/utils/backendAPI.ts index bbee7897..a33c3dec 100644 --- a/rsconcept/frontend/src/utils/backendAPI.ts +++ b/rsconcept/frontend/src/utils/backendAPI.ts @@ -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) { AxiosPatch({ - title: 'Update Password', + title: 'Update password', endpoint: '/users/api/change-password', request: request }); } +export function postRequestPasswordReset(request: FrontPush) { + AxiosPost({ + title: 'Request password reset', + endpoint: '/users/api/password-reset', + request: request + }); +} + +export function postValidatePasswordToken(request: FrontPush) { + AxiosPost({ + title: 'Validate password token', + endpoint: '/users/api/password-reset/validate', + request: request + }); +} + +export function postResetPassword(request: FrontPush) { + AxiosPost({ + title: 'Reset password', + endpoint: '/users/api/password-reset/confirm', + request: request + }); +} + export function getActiveUsers(request: FrontPull) { AxiosGet({ title: 'Active users list',