feat(auth): restore password reset flow and polish auth UI

This commit is contained in:
lizerk 2026-04-12 02:05:13 +03:00
parent 7dcf84dcd1
commit f5a908e443
25 changed files with 1571 additions and 234 deletions

View File

@ -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.

View File

@ -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"),

View File

@ -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"],

View File

@ -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(

View File

@ -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)

View File

@ -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"],

View File

@ -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]:

View 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>

View File

@ -0,0 +1,8 @@
Здравствуйте{% if user.first_name %}, {{ user.first_name }}{% endif %}.
Для сброса пароля перейдите по ссылке:
{{ reset_link }}
Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.
{{ site_url }}

View File

@ -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,
)

View 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>
);
}

View File

@ -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 {

View 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>
);
}

View 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>
);
}

View File

@ -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>

View File

@ -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)}

View File

@ -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>

View 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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View 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>
);
}

View File

@ -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),
}),
};
}

View 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";

View File

@ -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;