diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py
index 80881d80..f4e0be1b 100644
--- a/backend/api/auth/serializers.py
+++ b/backend/api/auth/serializers.py
@@ -62,6 +62,80 @@ class SessionBulkActionResponseSerializer(serializers.Serializer):
revoked = serializers.IntegerField(read_only=True)
+class ChangePasswordRequestSerializer(serializers.Serializer):
+ current_password = serializers.CharField(
+ write_only=True,
+ trim_whitespace=False,
+ style={"input_type": "password"},
+ )
+ new_password = serializers.CharField(
+ write_only=True,
+ min_length=6,
+ trim_whitespace=False,
+ style={"input_type": "password"},
+ )
+ new_password_confirm = serializers.CharField(
+ write_only=True,
+ trim_whitespace=False,
+ style={"input_type": "password"},
+ )
+
+ def validate(self, attrs):
+ if attrs["new_password"] != attrs["new_password_confirm"]:
+ raise serializers.ValidationError(
+ {"new_password_confirm": "Подтверждение пароля не совпадает."}
+ )
+ if attrs["current_password"] == attrs["new_password"]:
+ raise serializers.ValidationError(
+ {"new_password": "Новый пароль должен отличаться от текущего."}
+ )
+ return attrs
+
+
+class ChangePasswordResponseSerializer(serializers.Serializer):
+ ok = serializers.BooleanField(read_only=True)
+
+
+class PasswordResetRequestSerializer(serializers.Serializer):
+ login = serializers.CharField(
+ write_only=True,
+ allow_blank=False,
+ trim_whitespace=True,
+ help_text="Email или телефон пользователя.",
+ )
+
+
+class PasswordResetRequestResponseSerializer(serializers.Serializer):
+ ok = serializers.BooleanField(read_only=True)
+
+
+class PasswordResetConfirmRequestSerializer(serializers.Serializer):
+ uid = serializers.CharField(write_only=True)
+ token = serializers.CharField(write_only=True)
+ new_password = serializers.CharField(
+ write_only=True,
+ min_length=6,
+ trim_whitespace=False,
+ style={"input_type": "password"},
+ )
+ new_password_confirm = serializers.CharField(
+ write_only=True,
+ trim_whitespace=False,
+ style={"input_type": "password"},
+ )
+
+ def validate(self, attrs):
+ if attrs["new_password"] != attrs["new_password_confirm"]:
+ raise serializers.ValidationError(
+ {"new_password_confirm": "Подтверждение пароля не совпадает."}
+ )
+ return attrs
+
+
+class PasswordResetConfirmResponseSerializer(serializers.Serializer):
+ ok = serializers.BooleanField(read_only=True)
+
+
class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
"""
Принимает email ИЛИ phone/phone_number + password.
diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py
index 6763d2ef..1979e84b 100644
--- a/backend/api/auth/urls.py
+++ b/backend/api/auth/urls.py
@@ -1,8 +1,11 @@
from django.urls import path
from .views import (
+ ChangePasswordAPIView,
JWTTokenRefreshView,
LogoutOtherSessionsAPIView,
+ PasswordResetAPIView,
+ PasswordResetConfirmAPIView,
PhoneOrEmailTokenObtainPairView,
RegisterAPIView,
ResendEmailAPIView,
@@ -14,6 +17,13 @@ from .views import (
urlpatterns = [
path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"),
+ path("password-reset/", PasswordResetAPIView.as_view(), name="password-reset"),
+ path(
+ "password-reset/confirm/",
+ PasswordResetConfirmAPIView.as_view(),
+ name="password-reset-confirm",
+ ),
+ path("change-password/", ChangePasswordAPIView.as_view(), name="change-password"),
path("register/", RegisterAPIView.as_view(), name="register"),
path("resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
path("verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py
index bad4a28e..35a04598 100644
--- a/backend/api/auth/views.py
+++ b/backend/api/auth/views.py
@@ -3,18 +3,24 @@ from __future__ import annotations
import logging
from datetime import timedelta
+from django.conf import settings
from api.v1.employees.serializers import (
EmailSerializer,
EmailVerifySerializer,
RegisterSerializer,
)
from api.v1.employees.views._helpers import Employee
-from api.v1.employees.views.mixins import LdapUserCreationMixin
+from api.v1.employees.views.mixins import LdapPasswordMixin, LdapUserCreationMixin
from common.emails import send_templated_mail
+from django.contrib.auth import authenticate
+from django.contrib.auth.tokens import default_token_generator
+from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
+from django.utils.encoding import force_bytes, force_str
+from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
@@ -33,7 +39,13 @@ from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .models import UserAuthSession
from .serializers import (
+ ChangePasswordRequestSerializer,
+ ChangePasswordResponseSerializer,
PhoneOrEmailTokenObtainPairSerializer,
+ PasswordResetConfirmRequestSerializer,
+ PasswordResetConfirmResponseSerializer,
+ PasswordResetRequestResponseSerializer,
+ PasswordResetRequestSerializer,
SessionBulkActionResponseSerializer,
SessionSerializer,
SessionTokenRefreshSerializer,
@@ -46,6 +58,25 @@ from .services import SESSION_ID_CLAIM
logger = logging.getLogger(__name__)
+def _resolve_employee_by_login(login: str) -> Employee | None:
+ normalized_login = (login or "").strip()
+ if not normalized_login:
+ return None
+
+ if "@" in normalized_login:
+ return Employee.objects.filter(email__iexact=normalized_login).first()
+
+ normalized_phone = _normalize_phone(normalized_login)
+ if not normalized_phone:
+ return None
+ return Employee.objects.filter(phone_number=normalized_phone).first()
+
+
+def _build_frontend_password_reset_link(*, uid: str, token: str) -> str:
+ site_url = getattr(settings, "SITE_URL", "https://corp.robotail.pro").rstrip("/")
+ return f"{site_url}/reset-password?uid={uid}&token={token}"
+
+
class AnonymousAPIView(APIView):
authentication_classes = []
throttle_scope = "anon"
@@ -375,6 +406,139 @@ class SessionBaseAPIView(APIView):
return token.get(SESSION_ID_CLAIM)
+class ChangePasswordAPIView(LdapPasswordMixin, SessionBaseAPIView):
+ @extend_schema(
+ tags=["Auth"],
+ summary="Сменить пароль текущего пользователя",
+ request=ChangePasswordRequestSerializer,
+ responses={
+ 200: ChangePasswordResponseSerializer,
+ 400: OpenApiResponse(
+ description="Текущий пароль неверен или новый пароль невалиден."
+ ),
+ 502: OpenApiResponse(
+ description="Не удалось обновить пароль в LDAP."
+ ),
+ },
+ )
+ def post(self, request):
+ serializer = ChangePasswordRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ current_password = serializer.validated_data["current_password"]
+ new_password = serializer.validated_data["new_password"]
+ user = request.user
+
+ verified_user = authenticate(
+ request=request,
+ email=user.email,
+ password=current_password,
+ )
+ if verified_user is None or verified_user.pk != user.pk:
+ return Response(
+ {
+ "current_password": [
+ "Текущий пароль указан неверно."
+ ]
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ updated, error_response = self.update_ldap_password(user, new_password)
+ except DjangoValidationError as exc:
+ return Response(
+ {"new_password": exc.messages},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if not updated:
+ return error_response
+
+ return Response({"ok": True}, status=status.HTTP_200_OK)
+
+
+class PasswordResetAPIView(AnonymousAPIView):
+ @extend_schema(
+ tags=["Auth"],
+ summary="Запросить письмо для сброса пароля",
+ request=PasswordResetRequestSerializer,
+ responses={200: PasswordResetRequestResponseSerializer},
+ )
+ def post(self, request):
+ serializer = PasswordResetRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ user = _resolve_employee_by_login(serializer.validated_data["login"])
+ if user and user.email:
+ uid = urlsafe_base64_encode(force_bytes(user.pk))
+ token = default_token_generator.make_token(user)
+ reset_link = _build_frontend_password_reset_link(uid=uid, token=token)
+
+ send_templated_mail(
+ subject="Восстановление пароля",
+ to=[user.email],
+ template_base="emails/password_reset_link",
+ context={
+ "user": user,
+ "reset_link": reset_link,
+ "site_url": getattr(settings, "SITE_URL", "https://corp.robotail.pro"),
+ },
+ )
+
+ # Намеренно не раскрываем, существует ли пользователь.
+ return Response({"ok": True}, status=status.HTTP_200_OK)
+
+
+class PasswordResetConfirmAPIView(LdapPasswordMixin, AnonymousAPIView):
+ @extend_schema(
+ tags=["Auth"],
+ summary="Подтвердить сброс пароля по uid и token",
+ request=PasswordResetConfirmRequestSerializer,
+ responses={
+ 200: PasswordResetConfirmResponseSerializer,
+ 400: OpenApiResponse(description="Ссылка недействительна или пароль невалиден."),
+ 502: OpenApiResponse(description="Не удалось обновить пароль в LDAP."),
+ },
+ )
+ def post(self, request):
+ serializer = PasswordResetConfirmRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ try:
+ user_id = force_str(
+ urlsafe_base64_decode(serializer.validated_data["uid"])
+ )
+ except (TypeError, ValueError, OverflowError):
+ return Response(
+ {"token": ["Ссылка для восстановления недействительна."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ user = Employee.objects.filter(pk=user_id).first()
+ if user is None or not default_token_generator.check_token(
+ user, serializer.validated_data["token"]
+ ):
+ return Response(
+ {"token": ["Ссылка для восстановления недействительна или устарела."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ updated, error_response = self.update_ldap_password(
+ user,
+ serializer.validated_data["new_password"],
+ )
+ except DjangoValidationError as exc:
+ return Response(
+ {"new_password": exc.messages},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if not updated:
+ return error_response
+
+ return Response({"ok": True}, status=status.HTTP_200_OK)
+
+
class SessionListAPIView(SessionBaseAPIView):
@extend_schema(
tags=["Auth"],
diff --git a/backend/api/v1/employees/views/mixins.py b/backend/api/v1/employees/views/mixins.py
index cab0712a..e70598d7 100644
--- a/backend/api/v1/employees/views/mixins.py
+++ b/backend/api/v1/employees/views/mixins.py
@@ -227,6 +227,10 @@ class LdapPasswordMixin:
group_cns=None,
move_to_department_dn=None,
)
+ # Сохраняем локальный пароль в unusable-состоянии заново,
+ # чтобы инвалидировать любые reset-токены Django.
+ employee.set_unusable_password()
+ employee.save(update_fields=["password"])
return True, None
except DirectoryLdapError as e:
logger.error(
diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py
index 91ac46df..ead29630 100644
--- a/backend/api/v1/feed/serializers.py
+++ b/backend/api/v1/feed/serializers.py
@@ -35,6 +35,11 @@ class AuthorMiniSerializer(serializers.ModelSerializer):
return build_media_url(obj.avatar, request)
+class PostLikerSerializer(AuthorMiniSerializer):
+ class Meta(AuthorMiniSerializer.Meta):
+ fields = ["id", "first_name", "last_name", "full_name", "avatar"]
+
+
class PostListSerializer(serializers.ModelSerializer):
author_id = serializers.IntegerField(read_only=True)
author = AuthorMiniSerializer(read_only=True)
diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py
index 05d05897..fe588946 100644
--- a/backend/api/v1/feed/views.py
+++ b/backend/api/v1/feed/views.py
@@ -31,7 +31,7 @@ from ..permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
)
-from .serializers import PostListSerializer, PostSerializer
+from .serializers import PostListSerializer, PostLikerSerializer, PostSerializer
Employee = get_user_model()
@@ -279,6 +279,27 @@ class PostViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
+ @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
+ def likers(self, request, pk=None):
+ """Возвращает список сотрудников, поставивших лайк посту."""
+ post = self.get_object()
+ queryset = (
+ Employee.objects.filter(post_likes__post=post)
+ .order_by("-post_likes__created_at", "-id")
+ .distinct()
+ )
+
+ page = self.paginate_queryset(queryset)
+ objects = page if page is not None else queryset
+ serializer = PostLikerSerializer(
+ objects,
+ many=True,
+ context={"request": request},
+ )
+ if page is not None:
+ return self.get_paginated_response(serializer.data)
+ return Response(serializer.data)
+
@action(
detail=True,
methods=["get", "post"],
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index eea106d0..7acf1504 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -5,6 +5,8 @@ from pathlib import Path
from dotenv import load_dotenv
+from dotenv import load_dotenv
+
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -13,7 +15,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# БАЗОВЫЕ НАСТРОЙКИ
# -----------------------------------------------------------------------------
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key")
-DEBUG = os.getenv("DEBUG", "false").lower() == "true"
+DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
def _split_env_list(value: str) -> list[str]:
diff --git a/backend/templates/emails/password_reset_link.html b/backend/templates/emails/password_reset_link.html
new file mode 100644
index 00000000..65c16317
--- /dev/null
+++ b/backend/templates/emails/password_reset_link.html
@@ -0,0 +1,11 @@
+
Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}.
+
+Для сброса пароля перейдите по ссылке:
+
+
+ {{ reset_link }}
+
+
+Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.
+
+{{ site_url }}
diff --git a/backend/templates/emails/password_reset_link.txt b/backend/templates/emails/password_reset_link.txt
new file mode 100644
index 00000000..aeec9bd1
--- /dev/null
+++ b/backend/templates/emails/password_reset_link.txt
@@ -0,0 +1,8 @@
+Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}.
+
+Для сброса пароля перейдите по ссылке:
+{{ reset_link }}
+
+Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.
+
+{{ site_url }}
diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py
index 0cf53cd2..5258ffd5 100644
--- a/backend/tests/api/auth/test_auth_and_registration.py
+++ b/backend/tests/api/auth/test_auth_and_registration.py
@@ -1,6 +1,8 @@
import re
import pytest
from datetime import timedelta
+from urllib.parse import parse_qs, urlparse
+from unittest.mock import Mock
from django.core import mail
from django.urls import reverse
@@ -46,6 +48,23 @@ def extract_code_from_last_email() -> str | None:
return m.group(1) if m else None
+def extract_reset_link_from_last_email() -> str:
+ assert mail.outbox, "Почтовый ящик пуст — письмо не отправлялось"
+ body = mail.outbox[-1].body
+ match = re.search(r"(https?://\S+)", body)
+ assert match, "В письме нет ссылки восстановления"
+ return match.group(1)
+
+
+def extract_reset_params_from_last_email() -> tuple[str, str]:
+ parsed = urlparse(extract_reset_link_from_last_email())
+ query = parse_qs(parsed.query)
+ uid = query.get("uid", [None])[0]
+ token = query.get("token", [None])[0] or query.get("amp;token", [None])[0]
+ assert uid and token, "В ссылке восстановления нет uid/token"
+ return uid, token
+
+
def register_payload(**overrides):
# Минимальный валидный PNG 1x1 пиксель (прозрачный) в base64
tiny_png_base64 = (
@@ -108,6 +127,34 @@ def auth_headers(access: str) -> dict[str, str]:
return {"HTTP_AUTHORIZATION": f"Bearer {access}"}
+def request_password_reset(api: APIClient, *, login: str):
+ return api.post(
+ reverse("api:password-reset"),
+ {"login": login},
+ format="json",
+ )
+
+
+def confirm_password_reset(
+ api: APIClient,
+ *,
+ uid: str,
+ token: str,
+ new_password: str,
+ route_name: str = "api:password-reset-confirm",
+):
+ return api.post(
+ reverse(route_name),
+ {
+ "uid": uid,
+ "token": token,
+ "new_password": new_password,
+ "new_password_confirm": new_password,
+ },
+ format="json",
+ )
+
+
# ======== регистрация: happy-path ========
@@ -311,6 +358,12 @@ def test_login_wrong_password(api):
("api:v1:token_obtain_pair", "/api/v1/auth/token/"),
("api:token_refresh", "/api/auth/token/refresh/"),
("api:v1:token_refresh", "/api/v1/auth/token/refresh/"),
+ ("api:password-reset", "/api/auth/password-reset/"),
+ ("api:v1:password-reset", "/api/v1/auth/password-reset/"),
+ ("api:password-reset-confirm", "/api/auth/password-reset/confirm/"),
+ ("api:v1:password-reset-confirm", "/api/v1/auth/password-reset/confirm/"),
+ ("api:change-password", "/api/auth/change-password/"),
+ ("api:v1:change-password", "/api/v1/auth/change-password/"),
("api:sessions", "/api/auth/sessions/"),
("api:v1:sessions", "/api/v1/auth/sessions/"),
("api:logout-others", "/api/auth/sessions/logout-others/"),
@@ -403,3 +456,189 @@ def test_delete_current_session_revokes_access_immediately(api):
after = api.get(reverse("api:v1:employees-me"), **auth_headers(access))
assert after.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+def test_change_password_updates_local_password(api):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ code = extract_code_from_last_email()
+ assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
+
+ login = jwt_obtain(api, email="ivan@example.com", password="Str0ngPass!")
+ assert login.status_code == status.HTTP_200_OK
+ access = login.json()["access"]
+
+ response = api.post(
+ reverse("api:change-password"),
+ {
+ "current_password": "Str0ngPass!",
+ "new_password": "NewStrongPass123!",
+ "new_password_confirm": "NewStrongPass123!",
+ },
+ format="json",
+ **auth_headers(access),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"ok": True}
+
+ old_login = jwt_obtain(api, email="ivan@example.com", password="Str0ngPass!")
+ assert old_login.status_code in (
+ status.HTTP_400_BAD_REQUEST,
+ status.HTTP_401_UNAUTHORIZED,
+ )
+
+ new_login = jwt_obtain(
+ api,
+ email="ivan@example.com",
+ password="NewStrongPass123!",
+ )
+ assert new_login.status_code == status.HTTP_200_OK
+
+
+def test_change_password_requires_valid_current_password(api):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ code = extract_code_from_last_email()
+ assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
+
+ login = jwt_obtain(api, email="ivan@example.com")
+ access = login.json()["access"]
+
+ response = api.post(
+ reverse("api:v1:change-password"),
+ {
+ "current_password": "BAD",
+ "new_password": "NewStrongPass123!",
+ "new_password_confirm": "NewStrongPass123!",
+ },
+ format="json",
+ **auth_headers(access),
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "current_password" in response.json()
+
+
+def test_change_password_uses_ldap_update_for_ldap_managed_user(api, monkeypatch):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ code = extract_code_from_last_email()
+ assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
+
+ login = jwt_obtain(api, email="ivan@example.com", password="Str0ngPass!")
+ assert login.status_code == status.HTTP_200_OK
+ access = login.json()["access"]
+
+ user = User.objects.get(email="ivan@example.com")
+ user.is_ldap_managed = True
+ user.set_unusable_password()
+ user.save(update_fields=["is_ldap_managed", "password"])
+
+ auth_mock = Mock(return_value=user)
+ update_mock = Mock(return_value=user)
+ monkeypatch.setattr("api.auth.views.authenticate", auth_mock)
+ monkeypatch.setattr("employees.ldap.UserService.update_user", update_mock)
+
+ response = api.post(
+ reverse("api:change-password"),
+ {
+ "current_password": "Str0ngPass!",
+ "new_password": "NewStrongPass123!",
+ "new_password_confirm": "NewStrongPass123!",
+ },
+ format="json",
+ **auth_headers(access),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ auth_mock.assert_called_once()
+ update_mock.assert_called_once_with(
+ emp=user,
+ changes={"password": "NewStrongPass123!"},
+ group_cns=None,
+ move_to_department_dn=None,
+ )
+
+
+def test_password_reset_request_sends_email_by_email_or_phone(api):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ user = User.objects.get(email="ivan@example.com")
+
+ by_email = request_password_reset(api, login="ivan@example.com")
+ assert by_email.status_code == status.HTTP_200_OK
+ assert by_email.json() == {"ok": True}
+ assert len(mail.outbox) == 2 # registration + reset
+
+ by_phone = request_password_reset(api, login=str(user.phone_number))
+ assert by_phone.status_code == status.HTTP_200_OK
+ assert by_phone.json() == {"ok": True}
+ assert len(mail.outbox) == 3
+
+
+def test_password_reset_request_does_not_leak_nonexistent_user(api):
+ response = request_password_reset(api, login="nobody@example.com")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"ok": True}
+ assert len(mail.outbox) == 0
+
+
+def test_password_reset_confirm_updates_local_password(api):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ code = extract_code_from_last_email()
+ assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
+ assert request_password_reset(api, login="ivan@example.com").status_code == 200
+ uid, token = extract_reset_params_from_last_email()
+
+ response = confirm_password_reset(
+ api,
+ uid=uid,
+ token=token,
+ new_password="RecoveredPass123!",
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"ok": True}
+
+ old_login = jwt_obtain(api, email="ivan@example.com", password="Str0ngPass!")
+ assert old_login.status_code in (
+ status.HTTP_400_BAD_REQUEST,
+ status.HTTP_401_UNAUTHORIZED,
+ )
+
+ new_login = jwt_obtain(api, email="ivan@example.com", password="RecoveredPass123!")
+ assert new_login.status_code == status.HTTP_200_OK
+
+
+def test_password_reset_confirm_rejects_invalid_token(api):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ response = confirm_password_reset(
+ api,
+ uid="bad",
+ token="broken",
+ new_password="RecoveredPass123!",
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "token" in response.json()
+
+
+def test_password_reset_confirm_uses_ldap_update_for_ldap_managed_user(api, monkeypatch):
+ assert register(api).status_code == status.HTTP_201_CREATED
+ user = User.objects.get(email="ivan@example.com")
+ user.is_ldap_managed = True
+ user.set_unusable_password()
+ user.save(update_fields=["is_ldap_managed", "password"])
+
+ assert request_password_reset(api, login="ivan@example.com").status_code == 200
+ uid, token = extract_reset_params_from_last_email()
+
+ update_mock = Mock(return_value=user)
+ monkeypatch.setattr("employees.ldap.UserService.update_user", update_mock)
+
+ response = confirm_password_reset(
+ api,
+ uid=uid,
+ token=token,
+ new_password="RecoveredPass123!",
+ route_name="api:v1:password-reset-confirm",
+ )
+ assert response.status_code == status.HTTP_200_OK
+ update_mock.assert_called_once_with(
+ emp=user,
+ changes={"password": "RecoveredPass123!"},
+ group_cns=None,
+ move_to_department_dn=None,
+ )
diff --git a/frontend/src/app/forgot-password/page.tsx b/frontend/src/app/forgot-password/page.tsx
new file mode 100644
index 00000000..9cd41f50
--- /dev/null
+++ b/frontend/src/app/forgot-password/page.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { ArrowLeft, KeyRound, Mail, UserRound } from "lucide-react";
+
+import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
+import { apiClient } from "@/lib/api";
+
+export default function ForgotPasswordPage() {
+ const [login, setLogin] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError("");
+ setLoading(true);
+
+ try {
+ await apiClient.requestPasswordReset({ login });
+ setSubmitted(true);
+ } catch (requestError) {
+ console.error("Password reset request failed", requestError);
+ setError("Не удалось отправить письмо для восстановления. Попробуйте позже.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
Восстановление доступа
+
+ Укажите почту или телефон аккаунта. Если такой профиль существует, на привязанный email
+ придет ссылка для сброса пароля.
+
+
+ {submitted ? (
+
+
+
+
+
+
+
Письмо отправлено
+
+ Если аккаунт найден, ссылка уже отправлена на email, привязанный к профилю.
+ Проверьте входящие и папку со спамом.
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ Вернуться ко входу
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index fd81b8a0..402a52c3 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -194,6 +194,23 @@ textarea {
color: var(--muted-foreground);
}
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+textarea:-webkit-autofill,
+textarea:-webkit-autofill:hover,
+textarea:-webkit-autofill:focus,
+select:-webkit-autofill,
+select:-webkit-autofill:hover,
+select:-webkit-autofill:focus {
+ -webkit-text-fill-color: var(--foreground);
+ caret-color: var(--foreground);
+ box-shadow:
+ inset 0 0 0 1000px var(--surface-secondary),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ transition: background-color 99999s ease-in-out 0s;
+}
+
.app-input:hover,
.app-select:hover,
.app-color-input:hover {
diff --git a/frontend/src/app/legal/privacy/page.tsx b/frontend/src/app/legal/privacy/page.tsx
new file mode 100644
index 00000000..df989dd4
--- /dev/null
+++ b/frontend/src/app/legal/privacy/page.tsx
@@ -0,0 +1,114 @@
+import Link from "next/link";
+
+import {
+ LEGAL_CONTACT_EMAIL,
+ LEGAL_OPERATOR_NAME,
+ LEGAL_SITE_URL,
+} from "@/lib/legal";
+
+const sections = [
+ {
+ title: "1. Общие положения",
+ body: [
+ "Настоящая Политика определяет порядок обработки персональных данных пользователей сервиса EUSRR и действует в отношении всей информации, которую оператор может получить при использовании сайта, внутренних веб-интерфейсов и связанных сервисов.",
+ "Политика подготовлена с учетом требований законодательства Российской Федерации о персональных данных, включая Федеральный закон от 27.07.2006 № 152-ФЗ.",
+ ],
+ },
+ {
+ title: "2. Оператор и контакты",
+ body: [
+ `Оператор: ${LEGAL_OPERATOR_NAME}.`,
+ `Сайт сервиса: ${LEGAL_SITE_URL}.`,
+ `Контакт для обращений по персональным данным: ${LEGAL_CONTACT_EMAIL}.`,
+ ],
+ },
+ {
+ title: "3. Какие данные обрабатываются",
+ body: [
+ "ФИО, адрес электронной почты, номер телефона, должность, подразделение, аватар, контактные данные в мессенджерах, сведения о сессиях и устройствах, а также иные данные, которые пользователь самостоятельно указывает в профиле или при использовании сервиса.",
+ "Технические данные: IP-адрес, user-agent, сведения о браузере и устройстве, дата и время входа, журналы безопасности, cookies и аналогичные технические идентификаторы.",
+ ],
+ },
+ {
+ title: "4. Цели обработки",
+ body: [
+ "Регистрация и аутентификация пользователей, обеспечение доступа к корпоративным функциям сервиса, ведение профиля сотрудника, доставка уведомлений, обмен сообщениями, работа с документами, календарями, заявками и иными рабочими разделами.",
+ "Дополнительно данные могут использоваться для обеспечения безопасности, аудита действий, поддержки пользователей, восстановления доступа и выполнения обязанностей оператора по законодательству РФ.",
+ ],
+ },
+ {
+ title: "5. Правовые основания",
+ body: [
+ "Обработка осуществляется на основании согласия субъекта персональных данных, необходимости исполнения пользовательского соглашения и предоставления функциональности сервиса, а также в случаях, предусмотренных законодательством Российской Федерации.",
+ ],
+ },
+ {
+ title: "6. Передача и хранение",
+ body: [
+ "Оператор принимает организационные и технические меры для защиты персональных данных от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, предоставления и распространения.",
+ "Персональные данные хранятся не дольше, чем этого требуют цели обработки, законодательство РФ и внутренние обязанности оператора по безопасности и учету.",
+ "При использовании сервиса пользователь соглашается, что технические данные, необходимые для работы сервиса и обеспечения безопасности, могут храниться и обрабатываться в составе журналов и систем мониторинга.",
+ ],
+ },
+ {
+ title: "7. Права пользователя",
+ body: [
+ "Пользователь вправе запросить сведения об обработке своих персональных данных, уточнение, блокирование или уничтожение данных, если они являются неполными, устаревшими, неточными либо обрабатываются с нарушением закона.",
+ `Обращения направляются по адресу ${LEGAL_CONTACT_EMAIL}.`,
+ ],
+ },
+ {
+ title: "8. Cookies и технические идентификаторы",
+ body: [
+ "Сервис использует cookies, токены сессии и иные технические механизмы для аутентификации, сохранения пользовательских настроек, обеспечения безопасности и корректной работы интерфейса.",
+ ],
+ },
+ {
+ title: "9. Изменение политики",
+ body: [
+ "Оператор вправе обновлять настоящую Политику. Актуальная редакция публикуется на этой странице и применяется с момента размещения, если иное не указано дополнительно.",
+ ],
+ },
+];
+
+export default function PrivacyPage() {
+ return (
+
+
+
+
+
Legal
+
Политика конфиденциальности
+
+ Документ описывает порядок обработки персональных данных пользователей сервиса.
+
+
+
+
+ {sections.map((section) => (
+
+ {section.title}
+
+ {section.body.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ Вернуться ко входу
+
+
+ Условия использования
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/legal/terms/page.tsx b/frontend/src/app/legal/terms/page.tsx
new file mode 100644
index 00000000..21143566
--- /dev/null
+++ b/frontend/src/app/legal/terms/page.tsx
@@ -0,0 +1,110 @@
+import Link from "next/link";
+
+import {
+ LEGAL_CONTACT_EMAIL,
+ LEGAL_OPERATOR_NAME,
+ LEGAL_SITE_URL,
+} from "@/lib/legal";
+
+const sections = [
+ {
+ title: "1. Предмет",
+ body: [
+ `${LEGAL_OPERATOR_NAME} предоставляет пользователю доступ к сервису ${LEGAL_SITE_URL} и связанным интерфейсам для корпоративного взаимодействия, обмена сообщениями, работы с документами, календарями, уведомлениями и иными рабочими функциями.`,
+ ],
+ },
+ {
+ title: "2. Регистрация и доступ",
+ body: [
+ "Пользователь обязуется предоставлять достоверные данные при регистрации и поддерживать их актуальность.",
+ "Пользователь несет ответственность за сохранность учетных данных, устройств доступа и действий, совершенных под своей учетной записью.",
+ ],
+ },
+ {
+ title: "3. Правила использования сервиса",
+ body: [
+ "Запрещается использовать сервис с нарушением законодательства РФ, внутренних политик организации, правил информационной безопасности и прав третьих лиц.",
+ "Запрещается пытаться получить несанкционированный доступ к чужим данным, учетным записям, разделам системы, журналам, API и внутренним административным функциям.",
+ ],
+ },
+ {
+ title: "4. Контент и данные",
+ body: [
+ "Пользователь отвечает за законность и корректность размещаемых сообщений, файлов, комментариев, публикаций и иных материалов.",
+ "Оператор вправе ограничить доступ к контенту, который нарушает закон, права других пользователей, требования безопасности или внутренние регламенты.",
+ ],
+ },
+ {
+ title: "5. Безопасность аккаунта",
+ body: [
+ "Пользователь обязан использовать надежный пароль, не передавать его третьим лицам и своевременно завершать лишние сессии на устройствах и в браузерах.",
+ "При подозрении на компрометацию учетных данных пользователь обязан немедленно сменить пароль и уведомить администратора сервиса.",
+ ],
+ },
+ {
+ title: "6. Ограничение ответственности",
+ body: [
+ "Оператор принимает разумные меры для обеспечения доступности и безопасности сервиса, но не гарантирует абсолютное отсутствие технических сбоев, перерывов, ошибок и ограничений, связанных с инфраструктурой связи, браузером, устройством пользователя или действиями третьих лиц.",
+ ],
+ },
+ {
+ title: "7. Персональные данные",
+ body: [
+ "Использование сервиса означает ознакомление с политикой конфиденциальности и согласие пользователя на обработку персональных данных в объеме, необходимом для работы сервиса и исполнения обязанностей оператора.",
+ ],
+ },
+ {
+ title: "8. Изменение условий",
+ body: [
+ "Оператор вправе изменять настоящие условия. Актуальная редакция публикуется на этой странице и применяется с момента размещения, если иное не указано отдельно.",
+ ],
+ },
+ {
+ title: "9. Контакты",
+ body: [
+ `По вопросам работы сервиса и исполнения настоящих условий можно обращаться по адресу ${LEGAL_CONTACT_EMAIL}.`,
+ ],
+ },
+];
+
+export default function TermsPage() {
+ return (
+
+
+
+
+
Legal
+
Условия использования
+
+ Общие правила доступа и использования сервиса EUSRR.
+
+
+
+
+ {sections.map((section) => (
+
+ {section.title}
+
+ {section.body.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ Вернуться ко входу
+
+
+ Политика конфиденциальности
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
index 43c65495..1fcaede8 100644
--- a/frontend/src/app/login/page.tsx
+++ b/frontend/src/app/login/page.tsx
@@ -3,14 +3,18 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
-import { apiClient } from "@/lib/api";
+import { Eye, EyeOff, KeyRound, UserRound } from "lucide-react";
+
+import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
import { useUser } from "@/contexts/UserContext";
+import { apiClient } from "@/lib/api";
export default function Login() {
const router = useRouter();
const { refreshUser } = useUser();
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -20,19 +24,14 @@ export default function Login() {
setLoading(true);
try {
- // Определяем email это или телефон
const credentials = login.includes("@")
? { email: login, password }
: { phone: login, password };
await apiClient.login(credentials);
-
- // Обновляем данные пользователя в контексте
await refreshUser();
-
- // Перенаправляем на главную
router.push("/");
- } catch (err: any) {
+ } catch (err) {
console.error("Ошибка авторизации:", err);
setError("Неверный логин или пароль");
} finally {
@@ -41,84 +40,98 @@ export default function Login() {
};
return (
-
-
-
-
+
+
+
+
Войти в аккаунт
-
+
Используйте телефон или почту, чтобы продолжить.
- {error && (
-
+ {error ? (
+
{error}
- )}
+ ) : null}
-
- Продолжая, вы соглашаетесь с условиями использования и подтверждаете ознакомление с политикой конфиденциальности.
-
+
+
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 1a60c6b1..3be0b4b6 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -555,19 +555,6 @@ function HomePageContent() {
}
return (
- {canCreatePost ? (
-
-
-
- Создать публикацию
-
-
- ) : null}
-
{posts.length === 0 ? (
@@ -674,7 +661,7 @@ function HomePageContent() {
type="button"
disabled={likeBusyId === post.id}
onClick={() => handleLikeToggle(post)}
- className={`flex items-center gap-2 rounded-lg px-3 py-2 transition ${post.is_liked ? "app-selected-soft app-accent-text" : "app-action-ghost"}`}
+ className={`flex items-center gap-2 rounded-lg px-3 py-2 transition ${post.is_liked ? "app-selected app-accent-text" : "app-action-ghost"}`}
>
+ {canCreatePost && !createPostOpen ? (
+
+ ) : null}
+
setCreatePostOpen(false)}
diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx
index 8d8ddd7c..ad437562 100644
--- a/frontend/src/app/register/page.tsx
+++ b/frontend/src/app/register/page.tsx
@@ -4,6 +4,8 @@ import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
+import { Eye, EyeOff } from "lucide-react";
+import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
// Динамический импорт компонента кроппера (чтобы избежать SSR проблем)
const AvatarCropper = dynamic(() => import('@/components/AvatarCropper'), {
@@ -44,6 +46,8 @@ export default function Register() {
// Состояния для кроппера
const [showCropper, setShowCropper] = useState(false);
const [tempImage, setTempImage] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [formData, setFormData] = useState({
first_name: '',
@@ -68,6 +72,10 @@ export default function Register() {
'Фото профиля',
'Дополнительно'
];
+ const fieldBaseClass = "app-input w-full rounded-lg px-4 py-3 text-sm";
+ const fieldErrorClass =
+ "w-full rounded-lg border border-red-400/60 bg-red-500/10 px-4 py-3 text-sm text-[var(--foreground)] transition focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-500/20";
+ const labelClass = "app-field-label";
const handleInputChange = (e: React.ChangeEvent) => {
const { name, value } = e.target;
@@ -289,15 +297,15 @@ export default function Register() {
};
return (
-
+
-
+
{/* Заголовок */}
-
+
Регистрация
-
+
{stepTitles[currentStep - 1]} • Шаг {currentStep} из {totalSteps}
@@ -310,10 +318,10 @@ export default function Register() {
key={index}
className={`h-2 flex-1 rounded-full transition-all ${
index < currentStep
- ? 'bg-sky-500'
+ ? 'bg-[var(--accent-primary)]'
: index === currentStep - 1
- ? 'bg-sky-300'
- : 'bg-gray-200'
+ ? 'bg-[color-mix(in_srgb,var(--accent-primary)_45%,transparent)]'
+ : 'bg-[var(--border-subtle)]'
}`}
/>
))}
@@ -322,7 +330,7 @@ export default function Register() {
{/* Ошибка */}
{error && (
-
+
{error}
)}
@@ -333,7 +341,7 @@ export default function Register() {
-
+
Отчество
-
+
Дата рождения *
-
+
Пол *
Выберите пол
Мужской
@@ -419,7 +427,7 @@ export default function Register() {
{currentStep === 2 && (
@@ -509,7 +537,7 @@ export default function Register() {
{currentStep === 3 && (
-
+
Фото профиля *
@@ -518,7 +546,7 @@ export default function Register() {
({ ...prev, avatar: '' }));
setAvatarPreview(null);
}}
- className="text-sm text-sky-600 hover:text-sky-700 font-medium"
+ className="app-link-accent text-sm font-medium"
>
Изменить фото
@@ -536,10 +564,10 @@ export default function Register() {
-
+
Загрузить фото
-
+
PNG, JPG до 5MB
-
+
Эти поля необязательны, но помогут коллегам связаться с вами
-
+
Telegram
-
+
WhatsApp
-
+
WeChat
@@ -633,7 +661,7 @@ export default function Register() {
type="button"
onClick={handleBack}
disabled={isLoading}
- className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200 disabled:opacity-50"
+ className="app-action-secondary flex-1 rounded-lg px-4 py-3 text-sm font-semibold disabled:opacity-50"
>
Назад
@@ -642,7 +670,7 @@ export default function Register() {
{isLoading ? (
@@ -663,7 +691,7 @@ export default function Register() {
{currentStep === 1 && (
Уже есть аккаунт? Войти
@@ -671,9 +699,7 @@ export default function Register() {
{currentStep === totalSteps && (
-
- Регистрируясь, вы принимаете условия использования и политику конфиденциальности
-
+
)}
diff --git a/frontend/src/app/reset-password/page.tsx b/frontend/src/app/reset-password/page.tsx
new file mode 100644
index 00000000..7701acf0
--- /dev/null
+++ b/frontend/src/app/reset-password/page.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useMemo, useState } from "react";
+import { ArrowLeft, Eye, EyeOff, KeyRound, LockKeyhole } from "lucide-react";
+
+import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
+import { apiClient } from "@/lib/api";
+
+function ResetPasswordContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const uid = searchParams.get("uid") ?? "";
+ const token = searchParams.get("token") ?? "";
+ const hasResetParams = Boolean(uid && token);
+
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [showNewPassword, setShowNewPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState("");
+
+ const submitDisabled = useMemo(
+ () => loading || !newPassword || !confirmPassword || !hasResetParams,
+ [confirmPassword, hasResetParams, loading, newPassword],
+ );
+
+ const handleSubmit = async (event: React.FormEvent
) => {
+ event.preventDefault();
+ setError("");
+ setLoading(true);
+
+ try {
+ await apiClient.confirmPasswordReset({
+ uid,
+ token,
+ new_password: newPassword,
+ new_password_confirm: confirmPassword,
+ });
+ setSuccess(true);
+ setTimeout(() => router.push("/login"), 1200);
+ } catch (requestError) {
+ console.error("Password reset confirmation failed", requestError);
+ setError("Ссылка недействительна, устарела или пароль не прошел проверку.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
Новый пароль
+
+ Установите новый пароль для входа в аккаунт.
+
+
+ {!hasResetParams ? (
+
+ В ссылке отсутствуют параметры восстановления. Запросите новую ссылку со страницы входа.
+
+ ) : null}
+
+ {success ? (
+
+
+
+
+
+
+
Пароль обновлен
+
+ Можно входить с новым паролем. Сейчас вернем вас на страницу входа.
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ Вернуться ко входу
+
+
+
+
+
+
+
+ );
+}
+
+export default function ResetPasswordPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
index 771efbe2..f0f7040d 100644
--- a/frontend/src/app/settings/page.tsx
+++ b/frontend/src/app/settings/page.tsx
@@ -6,6 +6,9 @@ import {
Bell,
Check,
Clock3,
+ Eye,
+ EyeOff,
+ KeyRound,
LogOut,
Mail,
Monitor,
@@ -224,6 +227,17 @@ export default function SettingsPage() {
const [sessionsLoading, setSessionsLoading] = useState(true);
const [sessionsUnavailable, setSessionsUnavailable] = useState(false);
const [sessionActionKey, setSessionActionKey] = useState(null);
+ const [passwordForm, setPasswordForm] = useState({
+ current_password: "",
+ new_password: "",
+ new_password_confirm: "",
+ });
+ const [changingPassword, setChangingPassword] = useState(false);
+ const [showPasswordFields, setShowPasswordFields] = useState({
+ current: false,
+ next: false,
+ confirm: false,
+ });
const {
isSupported,
@@ -569,6 +583,37 @@ export default function SettingsPage() {
}
};
+ const handleChangePassword = async () => {
+ if (!passwordForm.current_password || !passwordForm.new_password || !passwordForm.new_password_confirm) {
+ toast.error("Заполните все поля пароля");
+ return;
+ }
+ if (passwordForm.new_password !== passwordForm.new_password_confirm) {
+ toast.error("Подтверждение пароля не совпадает");
+ return;
+ }
+
+ try {
+ setChangingPassword(true);
+ await apiClient.changePassword(passwordForm);
+ setPasswordForm({
+ current_password: "",
+ new_password: "",
+ new_password_confirm: "",
+ });
+ toast.success("Пароль обновлен");
+ } catch (error) {
+ console.error(error);
+ toast.error(
+ error instanceof Error && error.message.includes("current_password")
+ ? "Текущий пароль указан неверно"
+ : "Не удалось изменить пароль",
+ );
+ } finally {
+ setChangingPassword(false);
+ }
+ };
+
if (loading || !user) {
return (
@@ -585,11 +630,10 @@ export default function SettingsPage() {
return (
-
+ /> */}
@@ -1080,6 +1124,133 @@ export default function SettingsPage() {
+
+
+
+
+
+
+
Смена пароля
+
+ Обновляет пароль для входа в аккаунт{user.is_ldap_managed ? " и LDAP-профиль" : ""}.
+
+
+
+
+
+
+ Текущий пароль
+
+
+ setPasswordForm((prev) => ({
+ ...prev,
+ current_password: event.target.value,
+ }))
+ }
+ className="app-input w-full rounded-lg px-4 py-3 pr-12 text-sm"
+ />
+
+ setShowPasswordFields((prev) => ({
+ ...prev,
+ current: !prev.current,
+ }))
+ }
+ className="app-icon-button absolute right-3 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
+ aria-label={showPasswordFields.current ? "Скрыть пароль" : "Показать пароль"}
+ >
+ {showPasswordFields.current ? : }
+
+
+
+
+ Новый пароль
+
+
+ setPasswordForm((prev) => ({
+ ...prev,
+ new_password: event.target.value,
+ }))
+ }
+ className="app-input w-full rounded-lg px-4 py-3 pr-12 text-sm"
+ />
+
+ setShowPasswordFields((prev) => ({
+ ...prev,
+ next: !prev.next,
+ }))
+ }
+ className="app-icon-button absolute right-3 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
+ aria-label={showPasswordFields.next ? "Скрыть пароль" : "Показать пароль"}
+ >
+ {showPasswordFields.next ? : }
+
+
+
+
+ Подтверждение
+
+
+ setPasswordForm((prev) => ({
+ ...prev,
+ new_password_confirm: event.target.value,
+ }))
+ }
+ className="app-input w-full rounded-lg px-4 py-3 pr-12 text-sm"
+ />
+
+ setShowPasswordFields((prev) => ({
+ ...prev,
+ confirm: !prev.confirm,
+ }))
+ }
+ className="app-icon-button absolute right-3 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
+ aria-label={showPasswordFields.confirm ? "Скрыть пароль" : "Показать пароль"}
+ >
+ {showPasswordFields.confirm ? : }
+
+
+
+
+
+
+
+ После смены пароля текущая сессия сохранится, остальные устройства можно завершить ниже.
+
+
void handleChangePassword()}
+ disabled={
+ changingPassword ||
+ !passwordForm.current_password ||
+ !passwordForm.new_password ||
+ !passwordForm.new_password_confirm
+ }
+ className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
+ >
+
+ {changingPassword ? "Сохраняем..." : "Сменить пароль"}
+
+
+
+
{sessionsLoading ? (
Загрузка активных сессий...
@@ -1143,7 +1314,7 @@ export default function SettingsPage() {
className="app-action-danger inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
- {sessionActionKey === `delete:${currentSession.session_id}` ? "Выходим..." : "Выйти с этого устройства"}
+ {sessionActionKey === `delete:${currentSession.session_id}` ? "Выходим..." : "Завершить"}
@@ -1171,7 +1342,7 @@ export default function SettingsPage() {
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
- {sessionActionKey === "logout-others" ? "Завершаем..." : "Завершить все остальные"}
+ {sessionActionKey === "logout-others" ? "Завершаем..." : "Завершить все"}
diff --git a/frontend/src/app/verify-email/page.tsx b/frontend/src/app/verify-email/page.tsx
index bd710aa1..6a581a99 100644
--- a/frontend/src/app/verify-email/page.tsx
+++ b/frontend/src/app/verify-email/page.tsx
@@ -1,8 +1,9 @@
'use client';
+import Link from 'next/link';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
-import Link from 'next/link';
+import { Check, Mail, RotateCw } from 'lucide-react';
export default function VerifyEmail() {
return (
@@ -14,10 +15,10 @@ export default function VerifyEmail() {
function VerifyEmailFallback() {
return (
-
-
-
-
Загрузка...
+
);
@@ -27,7 +28,7 @@ function VerifyEmailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get('email');
-
+
const [code, setCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState
(null);
@@ -48,13 +49,8 @@ function VerifyEmailContent() {
try {
const response = await fetch('/api/v1/auth/verify-email/', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- email,
- code: code.trim(),
- }),
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, code: code.trim() }),
});
const data = await response.json();
@@ -73,12 +69,9 @@ function VerifyEmailContent() {
}
setSuccess(true);
-
- // Редирект на страницу входа через 2 секунды
setTimeout(() => {
router.push('/login?verified=true');
}, 2000);
-
} catch (err) {
setError('Не удалось подключиться к серверу');
console.error('Verification error:', err);
@@ -95,9 +88,7 @@ function VerifyEmailContent() {
try {
const response = await fetch('/api/v1/auth/resend-email/', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
@@ -115,8 +106,7 @@ function VerifyEmailContent() {
}
setResendSuccess(true);
- setCode(''); // Очистить поле кода
-
+ setCode('');
} catch (err) {
setError('Не удалось отправить код');
console.error('Resend error:', err);
@@ -127,22 +117,18 @@ function VerifyEmailContent() {
if (success) {
return (
-
-
-
-
-
-
+
+
+
+
-
- Email подтвержден!
-
-
+
Email подтвержден
+
Ваш аккаунт активирован. Перенаправляем на страницу входа...
Войти в аккаунт
@@ -152,38 +138,32 @@ function VerifyEmailContent() {
}
return (
-
-
-
-
-
-
-
+
+
+
+
+
-
- Подтвердите email
-
-
+
Подтвердите email
+
Мы отправили код подтверждения на
- {email}
+ {email}
- {error && (
-
- {error}
-
- )}
+ {error ? (
+
{error}
+ ) : null}
- {resendSuccess && (
-
+ {resendSuccess ? (
+
Новый код отправлен на {email}
- )}
+ ) : null}
-
- Не получили код?{' '}
-
- Отправить повторно
-
-
+
+
+ Отправить повторно
+
-
+
← Вернуться к входу
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index df450aa2..3cbba862 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -330,7 +330,7 @@ function LeftNav() {
);
}
-export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: PageHeaderProps) {
+export function PageHeader({ title, subtitle, badge, eyebrow }: PageHeaderProps) {
return (
diff --git a/frontend/src/components/auth/AuthLegalNotice.tsx b/frontend/src/components/auth/AuthLegalNotice.tsx
new file mode 100644
index 00000000..caa626d1
--- /dev/null
+++ b/frontend/src/components/auth/AuthLegalNotice.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import Link from "next/link";
+
+export function AuthLegalNotice({
+ prefix = "Продолжая, вы соглашаетесь с ",
+}: {
+ prefix?: string;
+}) {
+ return (
+
+ {prefix}
+
+ условиями использования
+ {" "}
+ и подтверждаете ознакомление с{" "}
+
+ политикой конфиденциальности
+
+ .
+
+ );
+}
diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts
index e8be2870..fe06565a 100644
--- a/frontend/src/lib/api/auth.ts
+++ b/frontend/src/lib/api/auth.ts
@@ -1,4 +1,13 @@
-import type { AuthSession, SessionBulkActionResult } from "@/types/api";
+import type {
+ AuthSession,
+ ChangePasswordPayload,
+ ChangePasswordResult,
+ PasswordResetConfirmPayload,
+ PasswordResetConfirmResult,
+ PasswordResetRequestPayload,
+ PasswordResetRequestResult,
+ SessionBulkActionResult,
+} from "@/types/api";
import type { RequestFn } from "./utils";
@@ -9,5 +18,26 @@ export function createAuthApi(request: RequestFn) {
request(`/api/auth/sessions/${sessionId}/`, { method: "DELETE" }),
logoutOtherSessions: (): Promise
=>
request("/api/auth/sessions/logout-others/", { method: "POST" }),
+ changePassword: (
+ payload: ChangePasswordPayload,
+ ): Promise =>
+ request("/api/auth/change-password/", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ requestPasswordReset: (
+ payload: PasswordResetRequestPayload,
+ ): Promise =>
+ request("/api/auth/password-reset/", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ confirmPasswordReset: (
+ payload: PasswordResetConfirmPayload,
+ ): Promise =>
+ request("/api/auth/password-reset/confirm/", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
};
}
diff --git a/frontend/src/lib/legal.ts b/frontend/src/lib/legal.ts
new file mode 100644
index 00000000..04844da8
--- /dev/null
+++ b/frontend/src/lib/legal.ts
@@ -0,0 +1,3 @@
+export const LEGAL_OPERATOR_NAME = "Сервис EUSRR";
+export const LEGAL_SITE_URL = "https://corp.robotail.pro";
+export const LEGAL_CONTACT_EMAIL = "robotail-info@yandex.ru";
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 79f27d45..0e14cb1f 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -46,6 +46,7 @@ export interface User {
skills?: Skill[];
actions?: EmployeeAction[];
email_verified?: boolean;
+ is_ldap_managed?: boolean;
created_at?: string;
updated_at?: string;
last_login?: string;
@@ -75,6 +76,35 @@ export interface SessionBulkActionResult {
revoked: number;
}
+export interface ChangePasswordPayload {
+ current_password: string;
+ new_password: string;
+ new_password_confirm: string;
+}
+
+export interface ChangePasswordResult {
+ ok: boolean;
+}
+
+export interface PasswordResetRequestPayload {
+ login: string;
+}
+
+export interface PasswordResetRequestResult {
+ ok: boolean;
+}
+
+export interface PasswordResetConfirmPayload {
+ uid: string;
+ token: string;
+ new_password: string;
+ new_password_confirm: string;
+}
+
+export interface PasswordResetConfirmResult {
+ ok: boolean;
+}
+
export interface Department {
id: number;
name: string;