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, привязанный к профилю. + Проверьте входящие и папку со спамом. +

+
+
+
+ ) : ( +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+ + + + setLogin(e.target.value)} + className="app-input w-full rounded-lg py-3 pl-11 pr-4 text-sm" + placeholder="example@mail.com или +7 999 123-45-67" + required + /> +
+
+ + +
+ )} + +
+ + + Вернуться ко входу + +
+ + +
+
+
+ ); +} 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() {
-
-
-
-
-
-
-
-
@@ -509,7 +537,7 @@ export default function Register() { {currentStep === 3 && (
-
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 ? ( +
+
+ + + +
+

Пароль обновлен

+

+ Можно входить с новым паролем. Сейчас вернем вас на страницу входа. +

+
+
+
+ ) : ( +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+ + + + setNewPassword(e.target.value)} + className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm" + required + /> + +
+
+ +
+ +
+ + + + setConfirmPassword(e.target.value)} + className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm" + required + /> + +
+
+ + +
+ )} + +
+ + + Вернуться ко входу + +
+ + +
+
+
+ ); +} + +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-профиль" : ""}. +

+
+
+ +
+ + + +
+ +
+

+ После смены пароля текущая сессия сохранится, остальные устройства можно завершить ниже. +

+ +
+
+ {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;