feat(auth): restore password reset flow and polish auth UI
This commit is contained in:
parent
7dcf84dcd1
commit
f5a908e443
@ -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.
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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]:
|
||||
|
||||
11
backend/templates/emails/password_reset_link.html
Normal file
11
backend/templates/emails/password_reset_link.html
Normal file
@ -0,0 +1,11 @@
|
||||
<p>Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}.</p>
|
||||
|
||||
<p>Для сброса пароля перейдите по ссылке:</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ reset_link }}">{{ reset_link }}</a>
|
||||
</p>
|
||||
|
||||
<p>Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.</p>
|
||||
|
||||
<p>{{ site_url }}</p>
|
||||
8
backend/templates/emails/password_reset_link.txt
Normal file
8
backend/templates/emails/password_reset_link.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}.
|
||||
|
||||
Для сброса пароля перейдите по ссылке:
|
||||
{{ reset_link }}
|
||||
|
||||
Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.
|
||||
|
||||
{{ site_url }}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
115
frontend/src/app/forgot-password/page.tsx
Normal file
115
frontend/src/app/forgot-password/page.tsx
Normal file
@ -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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="app-shell min-h-screen text-[var(--foreground)]">
|
||||
<div className="mx-auto flex min-h-screen max-w-5xl items-center justify-center px-6 py-12 sm:px-10">
|
||||
<div className="app-surface-elevated w-full max-w-xl rounded-[2rem] p-8 sm:p-10">
|
||||
<div className="app-badge app-badge-accent mb-6 flex h-14 w-14 items-center justify-center rounded-2xl">
|
||||
<KeyRound size={26} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Восстановление доступа</h1>
|
||||
<p className="app-text-muted mt-3 text-sm leading-6">
|
||||
Укажите почту или телефон аккаунта. Если такой профиль существует, на привязанный email
|
||||
придет ссылка для сброса пароля.
|
||||
</p>
|
||||
|
||||
{submitted ? (
|
||||
<div className="app-surface-muted mt-6 rounded-2xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="app-badge app-badge-accent flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
|
||||
<Mail size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Письмо отправлено</p>
|
||||
<p className="app-text-muted mt-1 text-sm leading-6">
|
||||
Если аккаунт найден, ссылка уже отправлена на email, привязанный к профилю.
|
||||
Проверьте входящие и папку со спамом.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
{error ? (
|
||||
<div className="app-feedback-danger rounded-xl p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="app-field-label" htmlFor="forgot-login">
|
||||
Телефон или почта
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]">
|
||||
<UserRound size={18} />
|
||||
</span>
|
||||
<input
|
||||
id="forgot-login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="app-action-primary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Отправка..." : "Отправить ссылку"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Вернуться ко входу
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AuthLegalNotice prefix="Продолжая использовать сервис, вы соглашаетесь с " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
114
frontend/src/app/legal/privacy/page.tsx
Normal file
114
frontend/src/app/legal/privacy/page.tsx
Normal file
@ -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 (
|
||||
<div className="app-shell min-h-screen text-[var(--foreground)]">
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<div className="app-surface-elevated rounded-[2rem] p-8 sm:p-10">
|
||||
<div className="mb-8">
|
||||
<p className="app-text-muted text-xs uppercase tracking-[0.2em]">Legal</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold">Политика конфиденциальности</h1>
|
||||
<p className="app-text-muted mt-3 text-sm leading-6">
|
||||
Документ описывает порядок обработки персональных данных пользователей сервиса.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="app-surface-muted rounded-2xl p-5">
|
||||
<h2 className="text-base font-semibold">{section.title}</h2>
|
||||
<div className="mt-3 space-y-3">
|
||||
{section.body.map((paragraph) => (
|
||||
<p key={paragraph} className="app-text-muted text-sm leading-6">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link href="/login" className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Вернуться ко входу
|
||||
</Link>
|
||||
<Link href="/legal/terms" className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Условия использования
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/src/app/legal/terms/page.tsx
Normal file
110
frontend/src/app/legal/terms/page.tsx
Normal file
@ -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 (
|
||||
<div className="app-shell min-h-screen text-[var(--foreground)]">
|
||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||
<div className="app-surface-elevated rounded-[2rem] p-8 sm:p-10">
|
||||
<div className="mb-8">
|
||||
<p className="app-text-muted text-xs uppercase tracking-[0.2em]">Legal</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold">Условия использования</h1>
|
||||
<p className="app-text-muted mt-3 text-sm leading-6">
|
||||
Общие правила доступа и использования сервиса EUSRR.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="app-surface-muted rounded-2xl p-5">
|
||||
<h2 className="text-base font-semibold">{section.title}</h2>
|
||||
<div className="mt-3 space-y-3">
|
||||
{section.body.map((paragraph) => (
|
||||
<p key={paragraph} className="app-text-muted text-sm leading-6">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link href="/login" className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Вернуться ко входу
|
||||
</Link>
|
||||
<Link href="/legal/privacy" className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Политика конфиденциальности
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
|
||||
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-100">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
|
||||
<div className="app-shell min-h-screen text-[var(--foreground)]">
|
||||
<div className="mx-auto flex min-h-screen max-w-5xl items-center justify-center px-6 py-12 sm:px-10">
|
||||
<div className="app-surface-elevated w-full max-w-xl rounded-[2rem] p-8 sm:p-10">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-[var(--foreground)] sm:text-4xl">
|
||||
Войти в аккаунт
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-gray-600">
|
||||
<p className="app-text-muted mb-8 text-sm">
|
||||
Используйте телефон или почту, чтобы продолжить.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
{error ? (
|
||||
<div className="app-feedback-danger mb-4 rounded-xl p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="login">
|
||||
<label className="app-field-label" htmlFor="login">
|
||||
Телефон или почта
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]">
|
||||
<UserRound size={18} />
|
||||
</span>
|
||||
<input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
required
|
||||
className="app-input w-full rounded-lg py-3 pl-11 pr-4 text-sm"
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="password">
|
||||
<label className="app-field-label" htmlFor="password">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]">
|
||||
<KeyRound size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
Запомнить меня
|
||||
</label>
|
||||
<a className="font-medium text-sky-600 transition hover:text-sky-700" href="#">
|
||||
Забыли пароль?
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
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={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end text-sm">
|
||||
<Link href="/forgot-password" className="app-link-accent text-sm font-medium">
|
||||
Забыли пароль?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center rounded-lg bg-sky-400 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="app-action-primary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Вход..." : "Войти"}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex w-full items-center justify-center rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-sky-700 transition hover:border-sky-200 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-action-secondary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
|
||||
>
|
||||
Создать аккаунт
|
||||
</Link>
|
||||
</form>
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Продолжая, вы соглашаетесь с условиями использования и подтверждаете ознакомление с политикой конфиденциальности.
|
||||
</p>
|
||||
|
||||
<AuthLegalNotice />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -555,19 +555,6 @@ function HomePageContent() {
|
||||
}
|
||||
return (
|
||||
<AppShell>
|
||||
{canCreatePost ? (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreatePostModal}
|
||||
className="app-action-primary inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Создать публикацию
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-2xl p-8 text-center">
|
||||
@ -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"}`}
|
||||
>
|
||||
<Heart
|
||||
size={16}
|
||||
@ -730,6 +717,20 @@ function HomePageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreatePost && !createPostOpen ? (
|
||||
<div className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreatePostModal}
|
||||
className="app-action-primary pointer-events-auto inline-flex h-12 w-12 items-center justify-center rounded-full p-0 leading-none shadow-[var(--shadow-card)] transition active:scale-[0.98]"
|
||||
title="Создать публикацию"
|
||||
aria-label="Создать публикацию"
|
||||
>
|
||||
<Plus size={22} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
isOpen={createPostOpen}
|
||||
onClose={() => setCreatePostOpen(false)}
|
||||
|
||||
@ -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<string>('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<RegisterFormData>({
|
||||
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<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@ -289,15 +297,15 @@ export default function Register() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
|
||||
<div className="app-shell min-h-screen text-[var(--foreground)]">
|
||||
<div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-12 sm:px-10">
|
||||
<div className="mx-auto w-full max-w-lg rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-100">
|
||||
<div className="app-surface-elevated mx-auto w-full max-w-lg rounded-[2rem] p-8">
|
||||
{/* Заголовок */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-gray-900">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-[var(--foreground)]">
|
||||
Регистрация
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="app-text-muted text-sm">
|
||||
{stepTitles[currentStep - 1]} • Шаг {currentStep} из {totalSteps}
|
||||
</p>
|
||||
</div>
|
||||
@ -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 && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-800">
|
||||
<div className="app-feedback-danger mb-6 rounded-lg p-4 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -333,7 +341,7 @@ export default function Register() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="first_name">
|
||||
<label className={labelClass} htmlFor="first_name">
|
||||
Имя <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -343,12 +351,12 @@ export default function Register() {
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
placeholder="Иван"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="last_name">
|
||||
<label className={labelClass} htmlFor="last_name">
|
||||
Фамилия <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -358,14 +366,14 @@ export default function Register() {
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="patronymic">
|
||||
<label className={labelClass} htmlFor="patronymic">
|
||||
Отчество
|
||||
</label>
|
||||
<input
|
||||
@ -374,13 +382,13 @@ export default function Register() {
|
||||
type="text"
|
||||
value={formData.patronymic}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
placeholder="Иванович"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="birth_date">
|
||||
<label className={labelClass} htmlFor="birth_date">
|
||||
Дата рождения <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -391,12 +399,12 @@ export default function Register() {
|
||||
value={formData.birth_date}
|
||||
onChange={handleInputChange}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="gender">
|
||||
<label className={labelClass} htmlFor="gender">
|
||||
Пол <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
@ -405,7 +413,7 @@ export default function Register() {
|
||||
required
|
||||
value={formData.gender}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={`app-select ${fieldBaseClass}`}
|
||||
>
|
||||
<option value="">Выберите пол</option>
|
||||
<option value="1">Мужской</option>
|
||||
@ -419,7 +427,7 @@ export default function Register() {
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="email">
|
||||
<label className={labelClass} htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -430,10 +438,10 @@ export default function Register() {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="email"
|
||||
className={`w-full rounded-lg border px-4 py-3 text-sm text-gray-900 transition focus:outline-none focus:ring-2 ${
|
||||
className={`${
|
||||
fieldErrors.email
|
||||
? 'border-red-300 bg-red-50 focus:border-red-500 focus:ring-red-100'
|
||||
: 'border-gray-200 bg-gray-50 focus:border-sky-500 focus:bg-white focus:ring-sky-100'
|
||||
? fieldErrorClass
|
||||
: fieldBaseClass
|
||||
}`}
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
@ -443,7 +451,7 @@ export default function Register() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="phone_number">
|
||||
<label className={labelClass} htmlFor="phone_number">
|
||||
Телефон <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -454,52 +462,72 @@ export default function Register() {
|
||||
value={formData.phone_number}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="tel"
|
||||
className={`w-full rounded-lg border px-4 py-3 text-sm text-gray-900 transition focus:outline-none focus:ring-2 ${
|
||||
className={`${
|
||||
fieldErrors.phone_number
|
||||
? 'border-red-300 bg-red-50 focus:border-red-500 focus:ring-red-100'
|
||||
: 'border-gray-200 bg-gray-50 focus:border-sky-500 focus:bg-white focus:ring-sky-100'
|
||||
? fieldErrorClass
|
||||
: fieldBaseClass
|
||||
}`}
|
||||
placeholder="+7 (900) 123-45-67"
|
||||
/>
|
||||
{fieldErrors.phone_number ? (
|
||||
<p className="mt-1 text-sm text-red-600">{fieldErrors.phone_number}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-gray-500">Формат: +7XXXXXXXXXX</p>
|
||||
<p className="app-text-muted mt-1 text-xs">Формат: +7XXXXXXXXXX</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="password">
|
||||
<label className={labelClass} htmlFor="password">
|
||||
Пароль <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className={`${fieldBaseClass} pr-12`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
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={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="confirmPassword">
|
||||
<label className={labelClass} htmlFor="confirmPassword">
|
||||
Повторите пароль <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className={`${fieldBaseClass} pr-12`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
||||
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={showConfirmPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -509,7 +537,7 @@ export default function Register() {
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
<label className={labelClass}>
|
||||
Фото профиля <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
@ -518,7 +546,7 @@ export default function Register() {
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Предпросмотр"
|
||||
className="h-40 w-40 rounded-full object-cover ring-4 ring-sky-100"
|
||||
className="app-avatar-frame h-40 w-40 rounded-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@ -526,7 +554,7 @@ export default function Register() {
|
||||
setFormData(prev => ({ ...prev, avatar: '' }));
|
||||
setAvatarPreview(null);
|
||||
}}
|
||||
className="text-sm text-sky-600 hover:text-sky-700 font-medium"
|
||||
className="app-link-accent text-sm font-medium"
|
||||
>
|
||||
Изменить фото
|
||||
</button>
|
||||
@ -536,10 +564,10 @@ export default function Register() {
|
||||
<div className="w-full">
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-6 py-12 transition hover:border-sky-400 hover:bg-sky-50"
|
||||
className="app-surface-muted flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-12 transition hover:bg-[var(--surface-tertiary)]"
|
||||
>
|
||||
<svg
|
||||
className="h-12 w-12 text-gray-400"
|
||||
className="app-text-muted h-12 w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -551,10 +579,10 @@ export default function Register() {
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm font-medium text-gray-700">
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground)]">
|
||||
Загрузить фото
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
PNG, JPG до 5MB
|
||||
</p>
|
||||
<input
|
||||
@ -575,12 +603,12 @@ export default function Register() {
|
||||
{/* Шаг 4: Дополнительно */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
<p className="app-text-muted mb-4 text-sm">
|
||||
Эти поля необязательны, но помогут коллегам связаться с вами
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="telegram">
|
||||
<label className={labelClass} htmlFor="telegram">
|
||||
Telegram
|
||||
</label>
|
||||
<input
|
||||
@ -589,13 +617,13 @@ export default function Register() {
|
||||
type="text"
|
||||
value={formData.telegram}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
placeholder="@username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="whatsapp">
|
||||
<label className={labelClass} htmlFor="whatsapp">
|
||||
WhatsApp
|
||||
</label>
|
||||
<input
|
||||
@ -604,13 +632,13 @@ export default function Register() {
|
||||
type="text"
|
||||
value={formData.whatsapp}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
placeholder="+7 (900) 123-45-67"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="wechat">
|
||||
<label className={labelClass} htmlFor="wechat">
|
||||
WeChat
|
||||
</label>
|
||||
<input
|
||||
@ -619,7 +647,7 @@ export default function Register() {
|
||||
type="text"
|
||||
value={formData.wechat}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className={fieldBaseClass}
|
||||
placeholder="WeChat ID"
|
||||
/>
|
||||
</div>
|
||||
@ -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"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
@ -642,7 +670,7 @@ export default function Register() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="app-action-primary flex-1 rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
@ -663,7 +691,7 @@ export default function Register() {
|
||||
{currentStep === 1 && (
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-4 flex w-full items-center justify-center rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-sky-700 transition hover:border-sky-200 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-action-secondary mt-4 flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
|
||||
>
|
||||
Уже есть аккаунт? Войти
|
||||
</Link>
|
||||
@ -671,9 +699,7 @@ export default function Register() {
|
||||
</form>
|
||||
|
||||
{currentStep === totalSteps && (
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Регистрируясь, вы принимаете условия использования и политику конфиденциальности
|
||||
</p>
|
||||
<AuthLegalNotice prefix="Регистрируясь, вы принимаете " />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
183
frontend/src/app/reset-password/page.tsx
Normal file
183
frontend/src/app/reset-password/page.tsx
Normal file
@ -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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="app-shell min-h-screen text-[var(--foreground)]">
|
||||
<div className="mx-auto flex min-h-screen max-w-5xl items-center justify-center px-6 py-12 sm:px-10">
|
||||
<div className="app-surface-elevated w-full max-w-xl rounded-[2rem] p-8 sm:p-10">
|
||||
<div className="app-badge app-badge-accent mb-6 flex h-14 w-14 items-center justify-center rounded-2xl">
|
||||
<LockKeyhole size={26} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Новый пароль</h1>
|
||||
<p className="app-text-muted mt-3 text-sm leading-6">
|
||||
Установите новый пароль для входа в аккаунт.
|
||||
</p>
|
||||
|
||||
{!hasResetParams ? (
|
||||
<div className="app-feedback-danger mt-6 rounded-xl p-4 text-sm">
|
||||
В ссылке отсутствуют параметры восстановления. Запросите новую ссылку со страницы входа.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{success ? (
|
||||
<div className="app-surface-muted mt-6 rounded-2xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="app-badge app-badge-accent flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
|
||||
<KeyRound size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Пароль обновлен</p>
|
||||
<p className="app-text-muted mt-1 text-sm leading-6">
|
||||
Можно входить с новым паролем. Сейчас вернем вас на страницу входа.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
{error ? (
|
||||
<div className="app-feedback-danger rounded-xl p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="app-field-label" htmlFor="new-password">
|
||||
Новый пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]">
|
||||
<KeyRound size={18} />
|
||||
</span>
|
||||
<input
|
||||
id="new-password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((prev) => !prev)}
|
||||
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={showNewPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
>
|
||||
{showNewPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="app-field-label" htmlFor="confirm-password">
|
||||
Подтвердите пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]">
|
||||
<KeyRound size={18} />
|
||||
</span>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="app-input w-full rounded-lg py-3 pl-11 pr-12 text-sm"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
||||
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={showConfirmPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled}
|
||||
className="app-action-primary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Сохранение..." : "Сохранить пароль"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Вернуться ко входу
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AuthLegalNotice prefix="Продолжая использовать сервис, вы соглашаетесь с " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -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<string | null>(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 (
|
||||
<AppShell>
|
||||
@ -585,11 +630,10 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
{/* <PageHeader
|
||||
title="Настройки"
|
||||
eyebrow="Личный кабинет"
|
||||
subtitle="Единая точка для темы, профиля, контактов и правил доставки уведомлений."
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<div className="space-y-6">
|
||||
<SectionCard
|
||||
@ -1042,7 +1086,7 @@ export default function SettingsPage() {
|
||||
<SectionCard
|
||||
id="access"
|
||||
title="Доступ"
|
||||
description="Текущее устройство, остальные активные сессии и действия по безопасности аккаунта."
|
||||
description="Пароль, текущее устройство, остальные активные сессии и действия по безопасности аккаунта."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr),minmax(280px,0.8fr)]">
|
||||
@ -1080,6 +1124,133 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-2xl p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
|
||||
<KeyRound size={18} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Смена пароля</p>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Обновляет пароль для входа в аккаунт{user.is_ldap_managed ? " и LDAP-профиль" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-2 block text-sm">Текущий пароль</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPasswordFields.current ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={passwordForm.current_password}
|
||||
onChange={(event) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
current_password: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="app-input w-full rounded-lg px-4 py-3 pr-12 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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 ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-2 block text-sm">Новый пароль</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPasswordFields.next ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
value={passwordForm.new_password}
|
||||
onChange={(event) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
new_password: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="app-input w-full rounded-lg px-4 py-3 pr-12 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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 ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-2 block text-sm">Подтверждение</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPasswordFields.confirm ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
value={passwordForm.new_password_confirm}
|
||||
onChange={(event) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
new_password_confirm: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="app-input w-full rounded-lg px-4 py-3 pr-12 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
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 ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="app-text-muted text-sm">
|
||||
После смены пароля текущая сессия сохранится, остальные устройства можно завершить ниже.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
{changingPassword ? "Сохраняем..." : "Сменить пароль"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="app-surface-muted rounded-2xl p-5">
|
||||
<p className="app-text-muted text-sm">Загрузка активных сессий...</p>
|
||||
@ -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"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{sessionActionKey === `delete:${currentSession.session_id}` ? "Выходим..." : "Выйти с этого устройства"}
|
||||
{sessionActionKey === `delete:${currentSession.session_id}` ? "Выходим..." : "Завершить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -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"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{sessionActionKey === "logout-others" ? "Завершаем..." : "Завершить все остальные"}
|
||||
{sessionActionKey === "logout-others" ? "Завершаем..." : "Завершить все"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white flex items-center justify-center px-6">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center ring-1 ring-gray-100">
|
||||
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-sky-600"></div>
|
||||
<p className="mt-3 text-sm text-gray-500">Загрузка...</p>
|
||||
<div className="app-shell flex min-h-screen items-center justify-center px-6">
|
||||
<div className="app-surface-elevated w-full max-w-md rounded-[2rem] p-8 text-center">
|
||||
<div className="mx-auto mb-4 h-5 w-5 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
|
||||
<p className="app-text-muted text-sm">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -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<string | null>(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 (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white flex items-center justify-center px-6">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center ring-1 ring-gray-100">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div className="app-shell flex min-h-screen items-center justify-center px-6">
|
||||
<div className="app-surface-elevated w-full max-w-md rounded-[2rem] p-8 text-center">
|
||||
<div className="app-badge app-badge-accent mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Check size={30} />
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-semibold text-gray-900">
|
||||
Email подтвержден!
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<h1 className="mb-2 text-2xl font-semibold text-[var(--foreground)]">Email подтвержден</h1>
|
||||
<p className="app-text-muted mb-6 text-sm">
|
||||
Ваш аккаунт активирован. Перенаправляем на страницу входа...
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-sky-500 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300"
|
||||
className="app-action-primary inline-flex items-center justify-center rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Войти в аккаунт
|
||||
</Link>
|
||||
@ -152,38 +138,32 @@ function VerifyEmailContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white flex items-center justify-center px-6">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 ring-1 ring-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sky-100">
|
||||
<svg className="h-8 w-8 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="app-shell flex min-h-screen items-center justify-center px-6">
|
||||
<div className="app-surface-elevated w-full max-w-md rounded-[2rem] p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="app-badge app-badge-accent mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Mail size={30} />
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-semibold text-gray-900">
|
||||
Подтвердите email
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
<h1 className="mb-2 text-2xl font-semibold text-[var(--foreground)]">Подтвердите email</h1>
|
||||
<p className="app-text-muted text-sm">
|
||||
Мы отправили код подтверждения на <br />
|
||||
<span className="font-medium text-gray-900">{email}</span>
|
||||
<span className="font-medium text-[var(--foreground)]">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="app-feedback-danger mb-6 rounded-xl p-4 text-sm">{error}</div>
|
||||
) : null}
|
||||
|
||||
{resendSuccess && (
|
||||
<div className="mb-6 rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-800">
|
||||
{resendSuccess ? (
|
||||
<div className="app-feedback-success mb-6 rounded-xl p-4 text-sm">
|
||||
Новый код отправлен на {email}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleVerify} className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="code">
|
||||
<label className="app-field-label" htmlFor="code">
|
||||
Код подтверждения
|
||||
</label>
|
||||
<input
|
||||
@ -194,52 +174,35 @@ function VerifyEmailContent() {
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-center text-lg font-semibold tracking-widest text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-4 py-3 text-center text-lg font-semibold tracking-[0.35em]"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Введите 6-значный код из письма
|
||||
</p>
|
||||
<p className="app-text-muted mt-2 text-xs">Введите 6-значный код из письма</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || code.length !== 6}
|
||||
className="w-full rounded-lg bg-sky-500 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="app-action-primary w-full rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Проверяем...
|
||||
</span>
|
||||
) : (
|
||||
'Подтвердить email'
|
||||
)}
|
||||
{isLoading ? 'Проверяем...' : 'Подтвердить email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Не получили код?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isLoading}
|
||||
className="font-medium text-sky-600 hover:text-sky-700 transition disabled:opacity-50"
|
||||
>
|
||||
Отправить повторно
|
||||
</button>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isLoading}
|
||||
className="app-link-accent inline-flex items-center gap-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
Отправить повторно
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition"
|
||||
>
|
||||
<Link href="/login" className="app-text-muted text-sm transition hover:text-[var(--foreground)]">
|
||||
← Вернуться к входу
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -330,7 +330,7 @@ function LeftNav() {
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: PageHeaderProps) {
|
||||
export function PageHeader({ title, subtitle, badge, eyebrow }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="app-surface flex flex-col gap-3 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
23
frontend/src/components/auth/AuthLegalNotice.tsx
Normal file
23
frontend/src/components/auth/AuthLegalNotice.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export function AuthLegalNotice({
|
||||
prefix = "Продолжая, вы соглашаетесь с ",
|
||||
}: {
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<p className="app-text-muted mt-6 text-center text-xs leading-5">
|
||||
{prefix}
|
||||
<Link href="/legal/terms" className="app-link-accent font-medium">
|
||||
условиями использования
|
||||
</Link>{" "}
|
||||
и подтверждаете ознакомление с{" "}
|
||||
<Link href="/legal/privacy" className="app-link-accent font-medium">
|
||||
политикой конфиденциальности
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@ -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<SessionBulkActionResult> =>
|
||||
request("/api/auth/sessions/logout-others/", { method: "POST" }),
|
||||
changePassword: (
|
||||
payload: ChangePasswordPayload,
|
||||
): Promise<ChangePasswordResult> =>
|
||||
request("/api/auth/change-password/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
requestPasswordReset: (
|
||||
payload: PasswordResetRequestPayload,
|
||||
): Promise<PasswordResetRequestResult> =>
|
||||
request("/api/auth/password-reset/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
confirmPasswordReset: (
|
||||
payload: PasswordResetConfirmPayload,
|
||||
): Promise<PasswordResetConfirmResult> =>
|
||||
request("/api/auth/password-reset/confirm/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
3
frontend/src/lib/legal.ts
Normal file
3
frontend/src/lib/legal.ts
Normal file
@ -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";
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user