Update auth flows and chat pending messages

This commit is contained in:
lizerk 2026-05-28 19:12:05 +03:00
parent 653e75ad8a
commit 2b8e02e1f7
25 changed files with 2736 additions and 116 deletions

View File

@ -80,3 +80,191 @@ class UserAuthSession(models.Model):
self.revoked_reason = reason
if commit:
self.save(update_fields=["revoked_at", "revoked_reason"])
class QrLoginToken(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="qr_login_tokens",
verbose_name="Пользователь",
)
token_hash = models.CharField(
max_length=128,
unique=True,
db_index=True,
verbose_name="Хеш QR-токена",
)
created_by_session = models.ForeignKey(
UserAuthSession,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_qr_login_tokens",
verbose_name="Сессия-источник",
)
used_session = models.ForeignKey(
UserAuthSession,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="used_qr_login_tokens",
verbose_name="Созданная сессия",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
expires_at = models.DateTimeField(db_index=True, verbose_name="Истекает")
used_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
verbose_name="Использован",
)
created_ip_address = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name="IP создания",
)
created_user_agent = models.TextField(
blank=True,
default="",
verbose_name="User-Agent создания",
)
used_ip_address = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name="IP использования",
)
used_user_agent = models.TextField(
blank=True,
default="",
verbose_name="User-Agent использования",
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "used_at", "expires_at"]),
]
verbose_name = "QR-токен входа"
verbose_name_plural = "QR-токены входа"
def __str__(self) -> str:
return f"{self.user_id}:{self.created_at:%Y-%m-%d %H:%M:%S}"
@property
def is_used(self) -> bool:
return self.used_at is not None
@property
def is_expired(self) -> bool:
return timezone.now() >= self.expires_at
class QrLoginRequest(models.Model):
scan_token_hash = models.CharField(
max_length=128,
unique=True,
db_index=True,
verbose_name="Хеш токена сканирования",
)
client_secret_hash = models.CharField(
max_length=128,
unique=True,
db_index=True,
verbose_name="Хеш секрета клиента",
)
requester_ip_address = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name="IP запрашивающего устройства",
)
requester_user_agent = models.TextField(
blank=True,
default="",
verbose_name="User-Agent запрашивающего устройства",
)
requester_device_name = models.CharField(
max_length=255,
blank=True,
default="",
verbose_name="Запрашивающее устройство",
)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="approved_qr_login_requests",
verbose_name="Подтвердил пользователь",
)
approved_by_session = models.ForeignKey(
UserAuthSession,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_qr_login_requests",
verbose_name="Подтвердила сессия",
)
used_session = models.ForeignKey(
UserAuthSession,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="qr_login_requests",
verbose_name="Созданная сессия",
)
denied_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="denied_qr_login_requests",
verbose_name="Отклонил пользователь",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
expires_at = models.DateTimeField(db_index=True, verbose_name="Истекает")
approved_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
verbose_name="Подтвержден",
)
denied_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
verbose_name="Отклонен",
)
claimed_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
verbose_name="Получен клиентом",
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["expires_at", "approved_at", "denied_at"]),
]
verbose_name = "QR-запрос входа"
verbose_name_plural = "QR-запросы входа"
def __str__(self) -> str:
return f"{self.requester_device_name or 'unknown'}:{self.created_at:%Y-%m-%d %H:%M:%S}"
@property
def is_expired(self) -> bool:
return timezone.now() >= self.expires_at
@property
def status(self) -> str:
if self.claimed_at is not None:
return "claimed"
if self.denied_at is not None:
return "denied"
if self.is_expired:
return "expired"
if self.approved_at is not None:
return "approved"
return "pending"

View File

@ -14,8 +14,7 @@ from rest_framework_simplejwt.serializers import (
from .models import UserAuthSession
from .services import (
SESSION_ID_CLAIM,
create_auth_session,
hash_refresh_token,
create_token_pair_for_session,
validate_refresh_session,
)
@ -27,6 +26,45 @@ class TokenPairResponseSerializer(serializers.Serializer):
access = serializers.CharField(read_only=True)
class QrLoginCreateResponseSerializer(serializers.Serializer):
token = serializers.CharField(read_only=True)
expires_at = serializers.DateTimeField(read_only=True)
class QrLoginExchangeRequestSerializer(serializers.Serializer):
token = serializers.CharField(write_only=True, trim_whitespace=True)
class QrLoginRequestCreateResponseSerializer(serializers.Serializer):
scan_token = serializers.CharField(read_only=True)
client_secret = serializers.CharField(read_only=True)
expires_at = serializers.DateTimeField(read_only=True)
device_name = serializers.CharField(read_only=True)
ip_address = serializers.IPAddressField(read_only=True, allow_null=True)
class QrLoginRequestStatusRequestSerializer(serializers.Serializer):
client_secret = serializers.CharField(write_only=True, trim_whitespace=True)
class QrLoginRequestStatusResponseSerializer(serializers.Serializer):
status = serializers.CharField(read_only=True)
refresh = serializers.CharField(read_only=True, required=False)
access = serializers.CharField(read_only=True, required=False)
class QrLoginRequestDetailResponseSerializer(serializers.Serializer):
status = serializers.CharField(read_only=True)
device_name = serializers.CharField(read_only=True)
ip_address = serializers.IPAddressField(read_only=True, allow_null=True)
created_at = serializers.DateTimeField(read_only=True)
expires_at = serializers.DateTimeField(read_only=True)
class QrLoginRequestActionResponseSerializer(serializers.Serializer):
status = serializers.CharField(read_only=True)
class TokenRefreshRequestSerializer(serializers.Serializer):
refresh = serializers.CharField(
help_text="Refresh token, полученный при логине."
@ -196,18 +234,14 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
if request is None:
raise AuthenticationFailed("request_context_missing")
session = create_auth_session(user=self.user, request=request)
refresh = self.get_token(self.user)
refresh[SESSION_ID_CLAIM] = str(session.session_id)
access = refresh.access_token
access[SESSION_ID_CLAIM] = str(session.session_id)
session.refresh_token_hash = hash_refresh_token(str(refresh))
session.save(update_fields=["refresh_token_hash"])
refresh, access, _session = create_token_pair_for_session(
user=self.user,
request=request,
)
return {
"refresh": str(refresh),
"access": str(access),
"refresh": refresh,
"access": access,
}

View File

@ -1,16 +1,20 @@
from __future__ import annotations
import secrets
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import salted_hmac
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings as jwt_api_settings
from rest_framework_simplejwt.tokens import RefreshToken
from .models import UserAuthSession
from .models import QrLoginRequest, QrLoginToken, UserAuthSession
User = get_user_model()
@ -22,6 +26,18 @@ def hash_refresh_token(raw_token: str) -> str:
return salted_hmac("api.auth.refresh", raw_token).hexdigest()
def hash_qr_login_token(raw_token: str) -> str:
return salted_hmac("api.auth.qr_login", raw_token).hexdigest()
def hash_qr_login_request_scan_token(raw_token: str) -> str:
return salted_hmac("api.auth.qr_login_request.scan", raw_token).hexdigest()
def hash_qr_login_request_client_secret(raw_token: str) -> str:
return salted_hmac("api.auth.qr_login_request.client", raw_token).hexdigest()
def extract_client_ip(request) -> str:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded_for:
@ -68,14 +84,289 @@ def build_device_name(user_agent: str) -> str:
def create_auth_session(*, user: User, request) -> UserAuthSession:
user_agent = extract_user_agent(request)
return UserAuthSession.objects.create(
return create_auth_session_from_client_info(
user=user,
ip_address=extract_client_ip(request) or None,
user_agent=user_agent,
)
def create_auth_session_from_client_info(
*,
user: User,
ip_address: str | None,
user_agent: str,
) -> UserAuthSession:
return UserAuthSession.objects.create(
user=user,
ip_address=ip_address,
user_agent=user_agent,
device_name=build_device_name(user_agent),
)
def create_token_pair_for_auth_session(
*,
user: User,
session: UserAuthSession,
) -> tuple[str, str, UserAuthSession]:
refresh = RefreshToken.for_user(user)
refresh[SESSION_ID_CLAIM] = str(session.session_id)
access = refresh.access_token
access[SESSION_ID_CLAIM] = str(session.session_id)
session.refresh_token_hash = hash_refresh_token(str(refresh))
session.save(update_fields=["refresh_token_hash"])
return str(refresh), str(access), session
def create_token_pair_for_session(
*,
user: User,
request,
) -> tuple[str, str, UserAuthSession]:
session = create_auth_session(user=user, request=request)
return create_token_pair_for_auth_session(user=user, session=session)
def get_qr_login_ttl() -> timedelta:
raw_seconds = getattr(settings, "QR_LOGIN_TOKEN_TTL_SECONDS", 120)
try:
seconds = int(raw_seconds)
except (TypeError, ValueError):
seconds = 120
return timedelta(seconds=max(30, min(seconds, 600)))
def get_qr_login_request_ttl() -> timedelta:
raw_seconds = getattr(settings, "QR_LOGIN_REQUEST_TTL_SECONDS", 120)
try:
seconds = int(raw_seconds)
except (TypeError, ValueError):
seconds = 120
return timedelta(seconds=max(30, min(seconds, 600)))
def create_qr_login_token(
*,
user: User,
request,
current_session_id: str | None = None,
) -> tuple[str, QrLoginToken]:
raw_token = secrets.token_urlsafe(32)
current_session = None
if current_session_id:
current_session = UserAuthSession.objects.filter(
user=user,
session_id=current_session_id,
revoked_at__isnull=True,
).first()
token = QrLoginToken.objects.create(
user=user,
token_hash=hash_qr_login_token(raw_token),
created_by_session=current_session,
expires_at=timezone.now() + get_qr_login_ttl(),
created_ip_address=extract_client_ip(request) or None,
created_user_agent=extract_user_agent(request),
)
return raw_token, token
@transaction.atomic
def exchange_qr_login_token(*, raw_token: str, request) -> tuple[str, str]:
token_hash = hash_qr_login_token(raw_token)
qr_token = (
QrLoginToken.objects.select_for_update()
.select_related("user")
.filter(token_hash=token_hash)
.first()
)
if (
qr_token is None
or qr_token.is_used
or qr_token.is_expired
or not qr_token.user.is_active
or not getattr(qr_token.user, "email_verified", False)
):
raise InvalidToken("qr_login_token_invalid")
refresh, access, session = create_token_pair_for_session(
user=qr_token.user,
request=request,
)
qr_token.used_at = timezone.now()
qr_token.used_session = session
qr_token.used_ip_address = extract_client_ip(request) or None
qr_token.used_user_agent = extract_user_agent(request)
qr_token.save(
update_fields=[
"used_at",
"used_session",
"used_ip_address",
"used_user_agent",
]
)
return refresh, access
def create_qr_login_request(*, request) -> tuple[str, str, QrLoginRequest]:
scan_token = secrets.token_urlsafe(32)
client_secret = secrets.token_urlsafe(32)
user_agent = extract_user_agent(request)
qr_request = QrLoginRequest.objects.create(
scan_token_hash=hash_qr_login_request_scan_token(scan_token),
client_secret_hash=hash_qr_login_request_client_secret(client_secret),
requester_ip_address=extract_client_ip(request) or None,
requester_user_agent=user_agent,
requester_device_name=build_device_name(user_agent),
expires_at=timezone.now() + get_qr_login_request_ttl(),
)
return scan_token, client_secret, qr_request
def _load_qr_login_request_by_scan_token(
raw_scan_token: str,
) -> QrLoginRequest:
qr_request = QrLoginRequest.objects.filter(
scan_token_hash=hash_qr_login_request_scan_token(raw_scan_token),
).first()
if qr_request is None:
raise InvalidToken("qr_login_request_not_found")
return qr_request
def _load_qr_login_request_by_client_secret(
raw_client_secret: str,
) -> QrLoginRequest:
qr_request = QrLoginRequest.objects.filter(
client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret),
).first()
if qr_request is None:
raise InvalidToken("qr_login_request_not_found")
return qr_request
def get_qr_login_request_for_approval(raw_scan_token: str) -> QrLoginRequest:
return _load_qr_login_request_by_scan_token(raw_scan_token)
@transaction.atomic
def approve_qr_login_request(
*,
raw_scan_token: str,
user: User,
current_session_id: str | None,
) -> QrLoginRequest:
qr_request = (
QrLoginRequest.objects.select_for_update()
.filter(scan_token_hash=hash_qr_login_request_scan_token(raw_scan_token))
.first()
)
if qr_request is None or qr_request.status != "pending":
raise InvalidToken("qr_login_request_not_pending")
current_session = None
if current_session_id:
current_session = UserAuthSession.objects.filter(
user=user,
session_id=current_session_id,
revoked_at__isnull=True,
).first()
qr_request.approved_by = user
qr_request.approved_by_session = current_session
qr_request.approved_at = timezone.now()
qr_request.save(
update_fields=["approved_by", "approved_by_session", "approved_at"]
)
return qr_request
@transaction.atomic
def deny_qr_login_request(
*,
raw_scan_token: str,
user: User,
) -> QrLoginRequest:
qr_request = (
QrLoginRequest.objects.select_for_update()
.filter(scan_token_hash=hash_qr_login_request_scan_token(raw_scan_token))
.first()
)
if qr_request is None or qr_request.status not in ("pending", "approved"):
raise InvalidToken("qr_login_request_closed")
qr_request.denied_by = user
qr_request.denied_at = timezone.now()
qr_request.save(update_fields=["denied_by", "denied_at"])
return qr_request
@transaction.atomic
def cancel_qr_login_request(*, raw_client_secret: str) -> QrLoginRequest:
qr_request = (
QrLoginRequest.objects.select_for_update()
.filter(
client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret),
)
.first()
)
if qr_request is None:
raise InvalidToken("qr_login_request_not_found")
if qr_request.status == "pending":
qr_request.denied_at = timezone.now()
qr_request.save(update_fields=["denied_at"])
return qr_request
@transaction.atomic
def poll_qr_login_request(
*,
raw_client_secret: str,
request,
) -> dict[str, str | None]:
qr_request = (
QrLoginRequest.objects.select_for_update()
.select_related("approved_by")
.filter(
client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret),
)
.first()
)
if qr_request is None:
raise InvalidToken("qr_login_request_not_found")
current_status = qr_request.status
if current_status != "approved":
return {"status": current_status}
user = qr_request.approved_by
if user is None or not user.is_active or not getattr(user, "email_verified", False):
qr_request.denied_at = timezone.now()
qr_request.save(update_fields=["denied_at"])
return {"status": "denied"}
user_agent = extract_user_agent(request) or qr_request.requester_user_agent
session = create_auth_session_from_client_info(
user=user,
ip_address=extract_client_ip(request) or qr_request.requester_ip_address,
user_agent=user_agent,
)
refresh, access, session = create_token_pair_for_auth_session(
user=user,
session=session,
)
qr_request.claimed_at = timezone.now()
qr_request.used_session = session
qr_request.save(update_fields=["claimed_at", "used_session"])
return {"status": "approved", "refresh": refresh, "access": access}
def _load_session_from_token(token: Any, *, user: User | None = None) -> UserAuthSession:
session_id = token.get(SESSION_ID_CLAIM)
if not session_id:

View File

@ -7,6 +7,14 @@ from .views import (
PasswordResetAPIView,
PasswordResetConfirmAPIView,
PhoneOrEmailTokenObtainPairView,
QrLoginCreateAPIView,
QrLoginExchangeAPIView,
QrLoginRequestApproveAPIView,
QrLoginRequestCancelAPIView,
QrLoginRequestCreateAPIView,
QrLoginRequestDenyAPIView,
QrLoginRequestDetailAPIView,
QrLoginRequestStatusAPIView,
RegisterAPIView,
ResendEmailAPIView,
SessionDetailAPIView,
@ -17,6 +25,42 @@ from .views import (
urlpatterns = [
path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"),
path("qr-login/", QrLoginCreateAPIView.as_view(), name="qr-login"),
path(
"qr-login/exchange/",
QrLoginExchangeAPIView.as_view(),
name="qr-login-exchange",
),
path(
"qr-login/requests/",
QrLoginRequestCreateAPIView.as_view(),
name="qr-login-request",
),
path(
"qr-login/requests/status/",
QrLoginRequestStatusAPIView.as_view(),
name="qr-login-request-status",
),
path(
"qr-login/requests/cancel/",
QrLoginRequestCancelAPIView.as_view(),
name="qr-login-request-cancel",
),
path(
"qr-login/requests/<str:scan_token>/",
QrLoginRequestDetailAPIView.as_view(),
name="qr-login-request-detail",
),
path(
"qr-login/requests/<str:scan_token>/approve/",
QrLoginRequestApproveAPIView.as_view(),
name="qr-login-request-approve",
),
path(
"qr-login/requests/<str:scan_token>/deny/",
QrLoginRequestDenyAPIView.as_view(),
name="qr-login-request-deny",
),
path("password-reset/", PasswordResetAPIView.as_view(), name="password-reset"),
path(
"password-reset/confirm/",

View File

@ -35,6 +35,7 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .models import UserAuthSession
@ -46,6 +47,13 @@ from .serializers import (
PasswordResetConfirmResponseSerializer,
PasswordResetRequestResponseSerializer,
PasswordResetRequestSerializer,
QrLoginCreateResponseSerializer,
QrLoginExchangeRequestSerializer,
QrLoginRequestActionResponseSerializer,
QrLoginRequestCreateResponseSerializer,
QrLoginRequestDetailResponseSerializer,
QrLoginRequestStatusRequestSerializer,
QrLoginRequestStatusResponseSerializer,
SessionBulkActionResponseSerializer,
SessionSerializer,
SessionTokenRefreshSerializer,
@ -53,7 +61,17 @@ from .serializers import (
TokenRefreshRequestSerializer,
TokenRefreshResponseSerializer,
)
from .services import SESSION_ID_CLAIM
from .services import (
SESSION_ID_CLAIM,
approve_qr_login_request,
cancel_qr_login_request,
create_qr_login_request,
create_qr_login_token,
deny_qr_login_request,
exchange_qr_login_token,
get_qr_login_request_for_approval,
poll_qr_login_request,
)
logger = logging.getLogger(__name__)
@ -135,6 +153,189 @@ class JWTTokenRefreshView(TokenRefreshView):
serializer_class = SessionTokenRefreshSerializer
class QrLoginExchangeAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Обменять одноразовый QR-токен на JWT access и refresh",
request=QrLoginExchangeRequestSerializer,
responses={
200: TokenPairResponseSerializer,
401: OpenApiResponse(
description="QR-токен недействителен, истек или уже использован."
),
},
)
def post(self, request):
serializer = QrLoginExchangeRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
refresh, access = exchange_qr_login_token(
raw_token=serializer.validated_data["token"],
request=request,
)
except InvalidToken:
return Response(
{"detail": "qr_login_token_invalid"},
status=status.HTTP_401_UNAUTHORIZED,
)
return Response(
{"refresh": refresh, "access": access},
status=status.HTTP_200_OK,
)
def _serialize_qr_login_request(qr_request) -> dict:
return {
"status": qr_request.status,
"device_name": qr_request.requester_device_name,
"ip_address": qr_request.requester_ip_address,
"created_at": qr_request.created_at,
"expires_at": qr_request.expires_at,
}
def _get_current_session_id_from_request(request) -> str | None:
token = getattr(request, "auth", None)
if token is None:
return None
return token.get(SESSION_ID_CLAIM)
class QrLoginRequestCreateAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Создать QR-запрос входа для подтверждения на другом устройстве",
responses={200: QrLoginRequestCreateResponseSerializer},
)
def post(self, request):
scan_token, client_secret, qr_request = create_qr_login_request(
request=request,
)
return Response(
{
"scan_token": scan_token,
"client_secret": client_secret,
"expires_at": qr_request.expires_at,
"device_name": qr_request.requester_device_name,
"ip_address": qr_request.requester_ip_address,
},
status=status.HTTP_200_OK,
)
class QrLoginRequestStatusAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Проверить статус QR-запроса входа",
request=QrLoginRequestStatusRequestSerializer,
responses={200: QrLoginRequestStatusResponseSerializer},
)
def post(self, request):
serializer = QrLoginRequestStatusRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
payload = poll_qr_login_request(
raw_client_secret=serializer.validated_data["client_secret"],
request=request,
)
except InvalidToken:
return Response(
{"detail": "qr_login_request_invalid"},
status=status.HTTP_401_UNAUTHORIZED,
)
return Response(payload, status=status.HTTP_200_OK)
class QrLoginRequestCancelAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
summary="Отменить QR-запрос входа с устройства, которое показывает QR",
request=QrLoginRequestStatusRequestSerializer,
responses={200: QrLoginRequestActionResponseSerializer},
)
def post(self, request):
serializer = QrLoginRequestStatusRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
qr_request = cancel_qr_login_request(
raw_client_secret=serializer.validated_data["client_secret"],
)
except InvalidToken:
return Response(
{"detail": "qr_login_request_invalid"},
status=status.HTTP_401_UNAUTHORIZED,
)
return Response({"status": qr_request.status}, status=status.HTTP_200_OK)
class QrLoginRequestDetailAPIView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Auth"],
summary="Получить параметры QR-запроса входа",
responses={200: QrLoginRequestDetailResponseSerializer},
)
def get(self, request, scan_token):
try:
qr_request = get_qr_login_request_for_approval(scan_token)
except InvalidToken:
return Response(
{"detail": "qr_login_request_invalid"},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
_serialize_qr_login_request(qr_request),
status=status.HTTP_200_OK,
)
class QrLoginRequestApproveAPIView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Auth"],
summary="Подтвердить QR-запрос входа",
responses={200: QrLoginRequestActionResponseSerializer},
)
def post(self, request, scan_token):
try:
qr_request = approve_qr_login_request(
raw_scan_token=scan_token,
user=request.user,
current_session_id=_get_current_session_id_from_request(request),
)
except InvalidToken:
return Response(
{"detail": "qr_login_request_invalid"},
status=status.HTTP_401_UNAUTHORIZED,
)
return Response({"status": qr_request.status}, status=status.HTTP_200_OK)
class QrLoginRequestDenyAPIView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Auth"],
summary="Отклонить QR-запрос входа",
responses={200: QrLoginRequestActionResponseSerializer},
)
def post(self, request, scan_token):
try:
qr_request = deny_qr_login_request(
raw_scan_token=scan_token,
user=request.user,
)
except InvalidToken:
return Response(
{"detail": "qr_login_request_invalid"},
status=status.HTTP_401_UNAUTHORIZED,
)
return Response({"status": qr_request.status}, status=status.HTTP_200_OK)
class ResendEmailAPIView(AnonymousAPIView):
@extend_schema(
tags=["Auth"],
@ -406,6 +607,24 @@ class SessionBaseAPIView(APIView):
return token.get(SESSION_ID_CLAIM)
class QrLoginCreateAPIView(SessionBaseAPIView):
@extend_schema(
tags=["Auth"],
summary="Создать одноразовый QR-токен для входа",
responses={200: QrLoginCreateResponseSerializer},
)
def post(self, request):
raw_token, token = create_qr_login_token(
user=request.user,
request=request,
current_session_id=self.get_current_session_id(request),
)
return Response(
{"token": raw_token, "expires_at": token.expires_at},
status=status.HTTP_200_OK,
)
class ChangePasswordAPIView(LdapPasswordMixin, SessionBaseAPIView):
@extend_schema(
tags=["Auth"],

View File

@ -0,0 +1,131 @@
# Generated manually for QR login tokens.
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("api", "0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="QrLoginToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"token_hash",
models.CharField(
db_index=True,
max_length=128,
unique=True,
verbose_name="Хеш QR-токена",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Создан"),
),
(
"expires_at",
models.DateTimeField(db_index=True, verbose_name="Истекает"),
),
(
"used_at",
models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="Использован",
),
),
(
"created_ip_address",
models.GenericIPAddressField(
blank=True,
null=True,
verbose_name="IP создания",
),
),
(
"created_user_agent",
models.TextField(
blank=True,
default="",
verbose_name="User-Agent создания",
),
),
(
"used_ip_address",
models.GenericIPAddressField(
blank=True,
null=True,
verbose_name="IP использования",
),
),
(
"used_user_agent",
models.TextField(
blank=True,
default="",
verbose_name="User-Agent использования",
),
),
(
"created_by_session",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_qr_login_tokens",
to="api.userauthsession",
verbose_name="Сессия-источник",
),
),
(
"used_session",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="used_qr_login_tokens",
to="api.userauthsession",
verbose_name="Созданная сессия",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="qr_login_tokens",
to=settings.AUTH_USER_MODEL,
verbose_name="Пользователь",
),
),
],
options={
"verbose_name": "QR-токен входа",
"verbose_name_plural": "QR-токены входа",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="qrlogintoken",
index=models.Index(
fields=["user", "used_at", "expires_at"],
name="api_qrlogin_user_id_8e45eb_idx",
),
),
]

View File

@ -0,0 +1,164 @@
# Generated manually for QR login requests.
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("api", "0003_qrlogintoken"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="QrLoginRequest",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"scan_token_hash",
models.CharField(
db_index=True,
max_length=128,
unique=True,
verbose_name="Хеш токена сканирования",
),
),
(
"client_secret_hash",
models.CharField(
db_index=True,
max_length=128,
unique=True,
verbose_name="Хеш секрета клиента",
),
),
(
"requester_ip_address",
models.GenericIPAddressField(
blank=True,
null=True,
verbose_name="IP запрашивающего устройства",
),
),
(
"requester_user_agent",
models.TextField(
blank=True,
default="",
verbose_name="User-Agent запрашивающего устройства",
),
),
(
"requester_device_name",
models.CharField(
blank=True,
default="",
max_length=255,
verbose_name="Запрашивающее устройство",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Создан"),
),
(
"expires_at",
models.DateTimeField(db_index=True, verbose_name="Истекает"),
),
(
"approved_at",
models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="Подтвержден",
),
),
(
"denied_at",
models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="Отклонен",
),
),
(
"claimed_at",
models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="Получен клиентом",
),
),
(
"approved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="approved_qr_login_requests",
to=settings.AUTH_USER_MODEL,
verbose_name="Подтвердил пользователь",
),
),
(
"approved_by_session",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_qr_login_requests",
to="api.userauthsession",
verbose_name="Подтвердила сессия",
),
),
(
"denied_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="denied_qr_login_requests",
to=settings.AUTH_USER_MODEL,
verbose_name="Отклонил пользователь",
),
),
(
"used_session",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="qr_login_requests",
to="api.userauthsession",
verbose_name="Созданная сессия",
),
),
],
options={
"verbose_name": "QR-запрос входа",
"verbose_name_plural": "QR-запросы входа",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="qrloginrequest",
index=models.Index(
fields=["expires_at", "approved_at", "denied_at"],
name="api_qrlogin_expires_48a8f2_idx",
),
),
]

View File

@ -1,3 +1,3 @@
from api.auth.models import UserAuthSession
from api.auth.models import QrLoginRequest, QrLoginToken, UserAuthSession
__all__ = ["UserAuthSession"]
__all__ = ["QrLoginRequest", "QrLoginToken", "UserAuthSession"]

View File

@ -372,6 +372,8 @@ DEFAULT_FROM_EMAIL = os.getenv(
# Название сайта для email уведомлений и UI
SITE_NAME = os.getenv("SITE_NAME", "CORP - HiRo")
SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro")
QR_LOGIN_TOKEN_TTL_SECONDS = int(os.getenv("QR_LOGIN_TOKEN_TTL_SECONDS", "120"))
QR_LOGIN_REQUEST_TTL_SECONDS = int(os.getenv("QR_LOGIN_REQUEST_TTL_SECONDS", "120"))
# -----------------------------------------------------------------------------
# NOTIFICATIONS

View File

@ -16,6 +16,14 @@ class PushNotificationSender(BaseNotificationSender):
Доставляет уведомления в браузер даже когда вкладка закрыта.
"""
CHAT_NOTIFICATION_VERBS = {
"chat_new_message",
"chat_mention",
"chat_reply",
"announcement_new_message",
}
DEFAULT_TITLE = "Новое уведомление"
def can_send(self, notification, user_preferences) -> bool:
"""Проверяет, включены ли push уведомления"""
if not user_preferences.push_enabled:
@ -23,6 +31,25 @@ class PushNotificationSender(BaseNotificationSender):
return False
return True
def _get_actor_name(self, notification) -> str:
actor = notification.actor
if not actor:
return ""
return str(actor).strip()
def _get_data_title(self, notification) -> str:
data = notification.data if isinstance(notification.data, dict) else {}
title = data.get("title")
if not isinstance(title, str):
return ""
return title.strip()
def _build_title(self, notification) -> str:
if notification.verb in self.CHAT_NOTIFICATION_VERBS:
return self._get_actor_name(notification) or self.DEFAULT_TITLE
return self._get_data_title(notification) or self.DEFAULT_TITLE
def send(self, notification, **kwargs) -> bool:
"""
Отправляет Web Push уведомление.
@ -44,11 +71,8 @@ class PushNotificationSender(BaseNotificationSender):
self.log_skip(notification, "no active push devices")
return False
# Формируем данные для push
actor_str = (
str(notification.actor) if notification.actor else "Система"
)
title = f"{actor_str} {notification.verb}"
# Формируем данные для push без технических verb в видимом тексте.
title = self._build_title(notification)
# Ограничиваем длину body.
# Web Push имеет лимит ~4KB на весь payload.
@ -66,6 +90,7 @@ class PushNotificationSender(BaseNotificationSender):
# django-push-notifications использует другой формат
# Формируем данные для Web Push API
message_data = {
"title": title,
"head": title,
"body": body,
"url": notification.action_url or "/",
@ -74,7 +99,6 @@ class PushNotificationSender(BaseNotificationSender):
"data": {
"url": notification.action_url or "/",
"notification_id": notification.id,
"verb": notification.verb,
},
}

View File

@ -8,7 +8,7 @@ from django.core import mail
from django.urls import reverse
from django.utils import timezone
from django.contrib.auth import get_user_model
from api.auth.models import UserAuthSession
from api.auth.models import QrLoginRequest, QrLoginToken, UserAuthSession
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
@ -358,6 +358,16 @@ 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:qr-login", "/api/auth/qr-login/"),
("api:v1:qr-login", "/api/v1/auth/qr-login/"),
("api:qr-login-exchange", "/api/auth/qr-login/exchange/"),
("api:v1:qr-login-exchange", "/api/v1/auth/qr-login/exchange/"),
("api:qr-login-request", "/api/auth/qr-login/requests/"),
("api:v1:qr-login-request", "/api/v1/auth/qr-login/requests/"),
("api:qr-login-request-status", "/api/auth/qr-login/requests/status/"),
("api:v1:qr-login-request-status", "/api/v1/auth/qr-login/requests/status/"),
("api:qr-login-request-cancel", "/api/auth/qr-login/requests/cancel/"),
("api:v1:qr-login-request-cancel", "/api/v1/auth/qr-login/requests/cancel/"),
("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/"),
@ -374,6 +384,35 @@ def test_auth_routes_are_mirrored(route_name, path):
assert reverse(route_name) == path
def test_qr_login_request_action_routes_are_mirrored():
scan_token = "scan-token"
assert (
reverse("api:qr-login-request-detail", kwargs={"scan_token": scan_token})
== f"/api/auth/qr-login/requests/{scan_token}/"
)
assert (
reverse("api:v1:qr-login-request-detail", kwargs={"scan_token": scan_token})
== f"/api/v1/auth/qr-login/requests/{scan_token}/"
)
assert (
reverse("api:qr-login-request-approve", kwargs={"scan_token": scan_token})
== f"/api/auth/qr-login/requests/{scan_token}/approve/"
)
assert (
reverse("api:v1:qr-login-request-approve", kwargs={"scan_token": scan_token})
== f"/api/v1/auth/qr-login/requests/{scan_token}/approve/"
)
assert (
reverse("api:qr-login-request-deny", kwargs={"scan_token": scan_token})
== f"/api/auth/qr-login/requests/{scan_token}/deny/"
)
assert (
reverse("api:v1:qr-login-request-deny", kwargs={"scan_token": scan_token})
== f"/api/v1/auth/qr-login/requests/{scan_token}/deny/"
)
def test_login_creates_auth_session_and_embeds_session_id(api):
assert register(api).status_code == status.HTTP_201_CREATED
code = extract_code_from_last_email()
@ -393,6 +432,218 @@ def test_login_creates_auth_session_and_embeds_session_id(api):
assert session.refresh_token_hash
def test_qr_login_creates_one_time_session(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")
assert login.status_code == status.HTTP_200_OK
access = login.json()["access"]
created = api.post(reverse("api:qr-login"), **auth_headers(access))
assert created.status_code == status.HTTP_200_OK
body = created.json()
assert body["token"]
assert body["expires_at"]
qr_token = QrLoginToken.objects.get()
assert qr_token.user.email == "ivan@example.com"
assert qr_token.token_hash
assert qr_token.token_hash != body["token"]
assert qr_token.used_at is None
exchanged = api.post(
reverse("api:qr-login-exchange"),
{"token": body["token"]},
format="json",
)
assert exchanged.status_code == status.HTTP_200_OK
exchanged_body = exchanged.json()
assert {"access", "refresh"} <= set(exchanged_body.keys())
qr_token.refresh_from_db()
assert qr_token.used_at is not None
assert qr_token.used_session is not None
new_access = exchanged_body["access"]
profile = api.get(reverse("api:v1:employees-me"), **auth_headers(new_access))
assert profile.status_code == status.HTTP_200_OK
reused = api.post(
reverse("api:v1:qr-login-exchange"),
{"token": body["token"]},
format="json",
)
assert reused.status_code == status.HTTP_401_UNAUTHORIZED
def test_qr_login_rejects_expired_token(api, settings):
settings.QR_LOGIN_TOKEN_TTL_SECONDS = 30
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"]
created = api.post(reverse("api:v1:qr-login"), **auth_headers(access))
assert created.status_code == status.HTTP_200_OK
QrLoginToken.objects.update(expires_at=timezone.now() - timedelta(seconds=1))
exchanged = api.post(
reverse("api:qr-login-exchange"),
{"token": created.json()["token"]},
format="json",
)
assert exchanged.status_code == status.HTTP_401_UNAUTHORIZED
def test_qr_login_request_approval_creates_session(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")
assert login.status_code == status.HTTP_200_OK
access = login.json()["access"]
created = api.post(
reverse("api:qr-login-request"),
format="json",
HTTP_USER_AGENT="Mozilla/5.0 (Windows NT 10.0) Chrome/124.0",
REMOTE_ADDR="10.10.0.25",
)
assert created.status_code == status.HTTP_200_OK
body = created.json()
assert body["scan_token"]
assert body["client_secret"]
assert body["expires_at"]
assert body["device_name"] == "Google Chrome on Windows"
assert body["ip_address"] == "10.10.0.25"
qr_request = QrLoginRequest.objects.get()
assert qr_request.status == "pending"
assert qr_request.scan_token_hash != body["scan_token"]
assert qr_request.client_secret_hash != body["client_secret"]
detail = api.get(
reverse(
"api:qr-login-request-detail",
kwargs={"scan_token": body["scan_token"]},
),
**auth_headers(access),
)
assert detail.status_code == status.HTTP_200_OK
detail_body = detail.json()
assert detail_body["status"] == "pending"
assert detail_body["device_name"] == "Google Chrome on Windows"
approved = api.post(
reverse(
"api:qr-login-request-approve",
kwargs={"scan_token": body["scan_token"]},
),
**auth_headers(access),
)
assert approved.status_code == status.HTTP_200_OK
assert approved.json()["status"] == "approved"
polled = api.post(
reverse("api:v1:qr-login-request-status"),
{"client_secret": body["client_secret"]},
format="json",
HTTP_USER_AGENT="Mozilla/5.0 (Macintosh) Chrome/124.0",
REMOTE_ADDR="10.10.0.26",
)
assert polled.status_code == status.HTTP_200_OK
polled_body = polled.json()
assert polled_body["status"] == "approved"
assert {"access", "refresh"} <= set(polled_body.keys())
qr_request.refresh_from_db()
assert qr_request.status == "claimed"
assert qr_request.used_session is not None
assert qr_request.used_session.user.email == "ivan@example.com"
profile = api.get(
reverse("api:v1:employees-me"),
**auth_headers(polled_body["access"]),
)
assert profile.status_code == status.HTTP_200_OK
reused = api.post(
reverse("api:qr-login-request-status"),
{"client_secret": body["client_secret"]},
format="json",
)
assert reused.status_code == status.HTTP_200_OK
assert reused.json() == {"status": "claimed"}
def test_qr_login_request_can_be_denied(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"]
created = api.post(reverse("api:qr-login-request"), format="json")
assert created.status_code == status.HTTP_200_OK
body = created.json()
denied = api.post(
reverse(
"api:qr-login-request-deny",
kwargs={"scan_token": body["scan_token"]},
),
**auth_headers(access),
)
assert denied.status_code == status.HTTP_200_OK
assert denied.json()["status"] == "denied"
polled = api.post(
reverse("api:qr-login-request-status"),
{"client_secret": body["client_secret"]},
format="json",
)
assert polled.status_code == status.HTTP_200_OK
assert polled.json() == {"status": "denied"}
def test_qr_login_request_rejects_expired_request(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"]
created = api.post(reverse("api:qr-login-request"), format="json")
assert created.status_code == status.HTTP_200_OK
body = created.json()
QrLoginRequest.objects.update(expires_at=timezone.now() - timedelta(seconds=1))
approved = api.post(
reverse(
"api:qr-login-request-approve",
kwargs={"scan_token": body["scan_token"]},
),
**auth_headers(access),
)
assert approved.status_code == status.HTTP_401_UNAUTHORIZED
polled = api.post(
reverse("api:qr-login-request-status"),
{"client_secret": body["client_secret"]},
format="json",
)
assert polled.status_code == status.HTTP_200_OK
assert polled.json() == {"status": "expired"}
def test_sessions_list_and_logout_others_revoke_other_session(api):
assert register(api).status_code == status.HTTP_201_CREATED
code = extract_code_from_last_email()

View File

@ -0,0 +1,142 @@
import json
from unittest.mock import patch
import pytest
from django.contrib.contenttypes.models import ContentType
from push_notifications.models import WebPushDevice
from notifications.models import Notification, UserChannelPreferences
from notifications.senders.push import PushNotificationSender
pytestmark = pytest.mark.django_db
def _make_push_device(user):
return WebPushDevice.objects.create(
user=user,
registration_id="https://push.example.test/endpoint",
p256dh="test-p256dh",
auth="test-auth",
browser="CHROME",
active=True,
)
def _disable_auto_delivery(user):
UserChannelPreferences.objects.create(
user=user,
web_enabled=False,
email_enabled=False,
push_enabled=False,
)
def _make_notification(user, **kwargs):
_disable_auto_delivery(user)
return Notification.objects.create(recipient=user, **kwargs)
def _send_and_load_payload(notification):
sender = PushNotificationSender()
with patch.object(WebPushDevice, "send_message", autospec=True) as send_message:
result = sender.send(notification)
assert result is True
send_message.assert_called_once()
return json.loads(send_message.call_args.args[1])
class TestPushNotificationSenderPayload:
def test_chat_push_uses_actor_name_without_technical_verb(
self, user_factory
):
recipient = user_factory(email="recipient@example.com")
actor = user_factory(
email="actor@example.com",
first_name="Олеся",
last_name="Рубцова",
)
_make_push_device(recipient)
notification = _make_notification(
recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.pk,
verb="chat_new_message",
description="Полинина",
action_url="/messages/123",
data={
"title": "Новое сообщение от Рубцова Олеся",
"chat_id": 123,
"message_id": 456,
},
)
payload = _send_and_load_payload(notification)
assert payload["title"] == "Рубцова Олеся"
assert payload["head"] == "Рубцова Олеся"
assert payload["body"] == "Полинина"
assert payload["url"] == "/messages/123"
assert payload["data"] == {
"url": "/messages/123",
"notification_id": notification.id,
}
assert "chat_new_message" not in json.dumps(payload)
assert "verb" not in payload["data"]
def test_non_chat_push_uses_notification_data_title(self, user_factory):
recipient = user_factory(email="request-recipient@example.com")
_make_push_device(recipient)
notification = _make_notification(
recipient,
verb="request_approved",
description="Ваша заявка одобрена",
action_url="/requests?request=10",
data={"title": "Заявка одобрена"},
)
payload = _send_and_load_payload(notification)
assert payload["title"] == "Заявка одобрена"
assert payload["head"] == "Заявка одобрена"
assert "request_approved" not in json.dumps(payload)
def test_push_title_falls_back_without_actor_or_data_title(
self, user_factory
):
recipient = user_factory(email="fallback-recipient@example.com")
_make_push_device(recipient)
notification = _make_notification(
recipient,
verb="system_announcement",
description="Системное уведомление",
action_url="/",
data={},
)
payload = _send_and_load_payload(notification)
assert payload["title"] == "Новое уведомление"
assert payload["head"] == "Новое уведомление"
assert "system_announcement" not in json.dumps(payload)
def test_push_body_is_still_truncated_to_300_characters(
self, user_factory
):
recipient = user_factory(email="long-body-recipient@example.com")
_make_push_device(recipient)
long_body = "а" * 350
notification = _make_notification(
recipient,
verb="document_ready",
description=long_body,
action_url="/documents",
data={"title": "Документ на ознакомление"},
)
payload = _send_and_load_payload(notification)
assert len(payload["body"]) == 300
assert payload["body"] == ("а" * 297) + "..."

View File

@ -2,21 +2,136 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Eye, EyeOff, KeyRound, UserRound } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import {
Eye,
EyeOff,
KeyRound,
Loader2,
QrCode,
RefreshCw,
UserRound,
} from "lucide-react";
import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import type { QrLoginRequestStatus } from "@/types/api";
type LoginMode = "password" | "qr";
type QrLoginViewState =
| "idle"
| "creating"
| "pending"
| "approved"
| "denied"
| "expired"
| "claimed"
| "error";
export default function Login() {
const router = useRouter();
const { refreshUser } = useUser();
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const qrCompletedRef = useRef(false);
const [mode, setMode] = useState<LoginMode>("password");
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [qrState, setQrState] = useState<QrLoginViewState>("idle");
const [qrError, setQrError] = useState("");
const [qrRequest, setQrRequest] = useState<{
clientSecret: string;
qrDataUrl: string;
expiresAt: string;
deviceName: string;
ipAddress?: string | null;
} | null>(null);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const startQrLogin = useCallback(async () => {
clearPollTimer();
qrCompletedRef.current = false;
const previousClientSecret = qrRequest?.clientSecret;
setQrRequest(null);
setQrError("");
setQrState("creating");
try {
if (previousClientSecret) {
await apiClient.cancelQrLoginRequest(previousClientSecret).catch((err) => {
console.warn("Не удалось отменить предыдущий QR-запрос:", err);
});
}
const request = await apiClient.createQrLoginRequest();
const scanUrl = `${window.location.origin}/login/qr/confirm?token=${encodeURIComponent(request.scan_token)}`;
const qrDataUrl = await QRCode.toDataURL(scanUrl, {
errorCorrectionLevel: "M",
margin: 1,
width: 240,
color: {
dark: "#111827",
light: "#ffffff",
},
});
setQrRequest({
clientSecret: request.client_secret,
qrDataUrl,
expiresAt: request.expires_at,
deviceName: request.device_name,
ipAddress: request.ip_address,
});
setQrState("pending");
} catch (err) {
console.error("Ошибка создания QR-входа:", err);
setQrError("Не удалось создать QR-код для входа");
setQrState("error");
}
}, [clearPollTimer, qrRequest?.clientSecret]);
const handleQrStatus = useCallback(
async (status: QrLoginRequestStatus) => {
clearPollTimer();
setQrState(status);
if (status === "approved") {
qrCompletedRef.current = true;
setQrError("");
await refreshUser().catch((err) => {
console.warn("Не удалось обновить пользователя после QR-входа:", err);
});
window.location.replace("/");
return;
}
if (status === "claimed" && apiClient.getToken()) {
qrCompletedRef.current = true;
window.location.replace("/");
return;
}
if (status === "denied") {
qrCompletedRef.current = true;
setQrError("Вход по QR отменен на авторизованном устройстве");
} else if (status === "expired") {
qrCompletedRef.current = true;
setQrError("QR-код устарел");
} else if (status === "claimed") {
qrCompletedRef.current = true;
setQrError("Этот QR-код уже был использован");
}
},
[clearPollTimer, refreshUser],
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -39,6 +154,70 @@ export default function Login() {
}
};
const handleCancelQrLogin = async () => {
clearPollTimer();
qrCompletedRef.current = true;
const clientSecret = qrRequest?.clientSecret;
setMode("password");
setQrRequest(null);
setQrState("idle");
setQrError("");
if (!clientSecret) return;
try {
await apiClient.cancelQrLoginRequest(clientSecret);
} catch (err) {
console.warn("Не удалось отменить QR-запрос:", err);
}
};
useEffect(() => {
if (mode === "qr" && !qrRequest && qrState === "idle") {
void startQrLogin();
}
}, [mode, qrRequest, qrState, startQrLogin]);
useEffect(() => {
if (mode !== "qr" || qrState !== "pending" || !qrRequest) return;
let cancelled = false;
const poll = async () => {
if (qrCompletedRef.current) return;
try {
const response = await apiClient.pollQrLoginRequest(qrRequest.clientSecret);
if (cancelled || qrCompletedRef.current) return;
if (response.status === "pending") {
pollTimerRef.current = setTimeout(poll, 1800);
return;
}
qrCompletedRef.current = true;
await handleQrStatus(response.status);
} catch (err) {
if (cancelled || qrCompletedRef.current) return;
console.error("Ошибка проверки QR-входа:", err);
setQrError("Не удалось проверить статус QR-входа");
setQrState("error");
}
};
pollTimerRef.current = setTimeout(poll, 900);
return () => {
cancelled = true;
clearPollTimer();
};
}, [clearPollTimer, handleQrStatus, mode, qrRequest, qrState]);
const qrExpiresAt = qrRequest
? new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date(qrRequest.expiresAt))
: "";
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">
@ -56,80 +235,171 @@ export default function Login() {
</div>
) : null}
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="app-field-label" htmlFor="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="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 className="app-surface-muted mb-6 grid grid-cols-2 gap-2 rounded-xl p-1">
<button
type="button"
onClick={() => void handleCancelQrLogin()}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
mode === "password" ? "app-action-primary" : "text-[var(--muted-foreground)]"
}`}
>
<KeyRound size={16} />
Пароль
</button>
<button
type="button"
onClick={() => setMode("qr")}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
mode === "qr" ? "app-action-primary" : "text-[var(--muted-foreground)]"
}`}
>
<QrCode size={16} />
QR-код
</button>
</div>
<div>
<label className="app-field-label" htmlFor="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="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="••••••••"
/>
{mode === "password" ? (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="app-field-label" htmlFor="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="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="app-field-label" htmlFor="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="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="••••••••"
/>
<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="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="app-action-secondary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
>
Создать аккаунт
</Link>
</form>
) : (
<div className="space-y-4">
<div className="app-surface-muted flex min-h-[280px] flex-col items-center justify-center rounded-2xl p-5 text-center">
{qrState === "creating" ? (
<Loader2 className="app-accent-text animate-spin" size={32} />
) : qrRequest ? (
<>
<div className="rounded-2xl bg-white p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrRequest.qrDataUrl}
alt="QR для входа"
className="h-48 w-48"
/>
</div>
<p className="app-text-muted mt-4 text-sm">
{qrState === "pending"
? "Ожидаем подтверждение на авторизованном устройстве"
: qrError || "QR-вход недоступен"}
</p>
<div className="mt-3 flex flex-wrap justify-center gap-2">
<span className="app-badge px-2.5 py-1 text-xs font-medium">
{qrRequest.deviceName}
</span>
{qrRequest.ipAddress ? (
<span className="app-badge px-2.5 py-1 text-xs font-medium">
IP: {qrRequest.ipAddress}
</span>
) : null}
<span className="app-badge px-2.5 py-1 text-xs font-medium">
До {qrExpiresAt}
</span>
</div>
</>
) : (
<p className="app-text-muted text-sm">
{qrError || "QR-код не создан"}
</p>
)}
</div>
{qrError ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{qrError}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<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 ? "Скрыть пароль" : "Показать пароль"}
onClick={() => void startQrLogin()}
disabled={qrState === "creating"}
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold disabled:opacity-50"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
<RefreshCw size={16} />
Обновить QR
</button>
<button
type="button"
onClick={() => void handleCancelQrLogin()}
className="app-action-secondary inline-flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
>
Отмена
</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="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="app-action-secondary flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
>
Создать аккаунт
</Link>
</form>
)}
<AuthLegalNotice />
</div>

View File

@ -0,0 +1,281 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Ban,
CheckCircle2,
Clock3,
Loader2,
LogIn,
MonitorSmartphone,
ShieldCheck,
XCircle,
} from "lucide-react";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import type { QrLoginRequestDetailResult, QrLoginRequestStatus } from "@/types/api";
type ConfirmState =
| "loading"
| "ready"
| "approving"
| "denying"
| "approved"
| "denied"
| "expired"
| "claimed"
| "unauthorized"
| "error";
const closedStateText: Record<Extract<ConfirmState, "approved" | "denied" | "expired" | "claimed">, string> = {
approved: "Запрос подтвержден. Новое устройство завершает вход.",
denied: "Запрос входа отменен.",
expired: "QR-код устарел.",
claimed: "Этот QR-код уже был использован.",
};
function getClosedStateText(status: QrLoginRequestStatus): string {
if (status === "approved" || status === "denied" || status === "expired" || status === "claimed") {
return closedStateText[status];
}
return "Запрос обработан.";
}
function getConfirmStateFromStatus(status: QrLoginRequestStatus): ConfirmState {
return status === "pending" ? "ready" : status;
}
export default function QrLoginConfirmPage() {
const { user, loading } = useUser();
const [scanToken, setScanToken] = useState("");
const [state, setState] = useState<ConfirmState>("loading");
const [message, setMessage] = useState("");
const [requestDetails, setRequestDetails] =
useState<QrLoginRequestDetailResult | null>(null);
const currentUserName = useMemo(() => {
if (!user) return "";
return [user.last_name, user.first_name, user.patronymic]
.filter(Boolean)
.join(" ") || user.email;
}, [user]);
const loadRequest = useCallback(async (token: string) => {
setState("loading");
setMessage("");
try {
const details = await apiClient.getQrLoginRequest(token);
setRequestDetails(details);
if (details.status === "pending") {
setState("ready");
return;
}
setState(details.status);
setMessage(getClosedStateText(details.status));
} catch (error) {
console.error("Ошибка загрузки QR-запроса:", error);
setState("error");
setMessage("QR-запрос не найден или уже недоступен.");
}
}, []);
const loadInitialRequest = useCallback(async () => {
const token = new URLSearchParams(window.location.search).get("token")?.trim();
if (!token) {
setState("error");
setMessage("В ссылке нет QR-токена.");
return;
}
setScanToken(token);
if (!user) {
setState("unauthorized");
setMessage("Откройте эту ссылку на устройстве, где уже выполнен вход.");
return;
}
await loadRequest(token);
}, [loadRequest, user]);
useEffect(() => {
if (loading) return;
const timerId = window.setTimeout(() => {
void loadInitialRequest();
}, 0);
return () => window.clearTimeout(timerId);
}, [loadInitialRequest, loading]);
const handleApprove = async () => {
if (!scanToken || state !== "ready") return;
setState("approving");
setMessage("");
try {
const result = await apiClient.approveQrLoginRequest(scanToken);
setState(getConfirmStateFromStatus(result.status));
setMessage(getClosedStateText(result.status));
} catch (error) {
console.error("Ошибка подтверждения QR-входа:", error);
setState("error");
setMessage("Не удалось подтвердить вход. Запрос мог устареть.");
}
};
const handleDeny = async () => {
if (!scanToken || state !== "ready") return;
setState("denying");
setMessage("");
try {
const result = await apiClient.denyQrLoginRequest(scanToken);
setState(getConfirmStateFromStatus(result.status));
setMessage(getClosedStateText(result.status));
} catch (error) {
console.error("Ошибка отмены QR-входа:", error);
setState("error");
setMessage("Не удалось отменить вход. Запрос мог устареть.");
}
};
const expiresAt = requestDetails
? new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date(requestDetails.expires_at))
: "";
const isBusy = state === "loading" || state === "approving" || state === "denying";
const statusIcon =
state === "approved" ? (
<CheckCircle2 className="app-accent-text" size={28} />
) : state === "denied" || state === "expired" || state === "claimed" || state === "error" ? (
<XCircle className="text-[var(--danger-foreground)]" size={28} />
) : isBusy ? (
<Loader2 className="app-accent-text animate-spin" size={28} />
) : (
<ShieldCheck className="app-accent-text" size={28} />
);
return (
<div className="app-shell min-h-screen text-[var(--foreground)]">
<div className="mx-auto flex min-h-screen max-w-xl items-center justify-center px-6 py-12">
<div className="app-surface-elevated w-full 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">
{statusIcon}
</div>
<h1 className="text-3xl font-semibold tracking-tight">
Подтверждение входа
</h1>
<p className="app-text-muted mt-3 text-sm">
Проверьте устройство и аккаунт перед подтверждением.
</p>
{requestDetails ? (
<div className="app-surface-muted mt-6 space-y-4 rounded-2xl p-5">
<div className="flex gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<MonitorSmartphone size={20} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{requestDetails.device_name}
</p>
<p className="app-text-muted mt-1 text-xs">
{requestDetails.ip_address ? `IP: ${requestDetails.ip_address}` : "IP не определен"}
</p>
</div>
</div>
<div className="flex gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Clock3 size={20} />
</span>
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
Действует до {expiresAt}
</p>
<p className="app-text-muted mt-1 text-xs">
Статус: {requestDetails.status}
</p>
</div>
</div>
</div>
) : null}
{user ? (
<div className="app-surface-muted mt-4 rounded-2xl p-5">
<p className="app-text-muted text-xs">Вход будет подтвержден аккаунтом</p>
<p className="mt-1 text-sm font-semibold text-[var(--foreground)]">
{currentUserName}
</p>
<p className="app-text-muted mt-1 text-xs">{user.email}</p>
</div>
) : null}
{message ? (
<div
className={`mt-5 rounded-xl p-3 text-sm ${
state === "approved" ? "app-feedback-success" : "app-feedback-danger"
}`}
>
{message}
</div>
) : null}
{state === "unauthorized" ? (
<Link
href="/login"
className="app-action-primary mt-6 inline-flex w-full items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold"
>
Войти на этом устройстве
</Link>
) : null}
{state === "ready" || state === "approving" || state === "denying" ? (
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => void handleApprove()}
disabled={isBusy}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
>
{state === "approving" ? <Loader2 className="animate-spin" size={16} /> : <LogIn size={16} />}
Войти
</button>
<button
type="button"
onClick={() => void handleDeny()}
disabled={isBusy}
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
>
{state === "denying" ? <Loader2 className="animate-spin" size={16} /> : <Ban size={16} />}
Отменить
</button>
</div>
) : null}
<Link
href="/"
className="app-action-secondary mt-6 inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold"
>
<ArrowLeft size={16} />
Назад
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { Loader2, QrCode } from "lucide-react";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
type QrLoginState = "loading" | "success" | "error";
export default function QrLoginPage() {
const { refreshUser } = useUser();
const startedRef = useRef(false);
const [state, setState] = useState<QrLoginState>("loading");
const [message, setMessage] = useState("Проверяем одноразовую ссылку...");
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
async function exchangeToken() {
const token = new URLSearchParams(window.location.search).get("token")?.trim();
if (!token) {
setState("error");
setMessage("В ссылке нет QR-токена.");
return;
}
try {
await apiClient.loginWithQrToken(token);
setState("success");
setMessage("Вход выполнен.");
await refreshUser().catch((error) => {
console.warn("Не удалось обновить пользователя после QR-входа:", error);
});
window.location.replace("/");
} catch (error) {
console.error("QR login failed", error);
apiClient.clearToken();
setState("error");
setMessage("QR-ссылка недействительна, устарела или уже использована.");
}
}
void exchangeToken();
}, [refreshUser]);
return (
<div className="app-shell min-h-screen text-[var(--foreground)]">
<div className="mx-auto flex min-h-screen max-w-xl items-center justify-center px-6 py-12">
<div className="app-surface-elevated w-full rounded-[2rem] p-8 text-center">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl app-surface-muted">
{state === "loading" ? (
<Loader2 className="animate-spin app-accent-text" size={26} />
) : (
<QrCode className="app-accent-text" size={26} />
)}
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Вход по QR
</h1>
<p className="app-text-muted mt-3 text-sm">{message}</p>
{state === "error" ? (
<Link
href="/login"
className="app-action-primary mt-6 inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold"
>
Войти с паролем
</Link>
) : null}
</div>
</div>
</div>
);
}

View File

@ -91,6 +91,7 @@ function MessageDialogPageContent() {
const messageInputRef = useRef<HTMLTextAreaElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const highlightTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingRetryFilesRef = useRef<Map<string, File[]>>(new Map());
/* ──────────────────────────────────────────────────────
* HOOK: Chat messages (state, pagination, pending)
@ -155,10 +156,19 @@ function MessageDialogPageContent() {
* stays visible even after mark-read flushes (until user scrolls down).
*/
const displayedUnreadCount = Math.max(cm.chat?.unread_count ?? 0, cm.newMessagesBelowCount);
const {
allowOneOlderProbe: cmAllowOneOlderProbe,
hasMoreOlder: cmHasMoreOlder,
jumpToMessage: cmJumpToMessage,
loadOlderMessages: cmLoadOlderMessages,
loadingOlder: cmLoadingOlder,
messagesLoading: cmMessagesLoading,
} = cm;
/* ── UI state ── */
const [messageText, setMessageText] = useState("");
const [sending, setSending] = useState(false);
const [retryingLocalIds, setRetryingLocalIds] = useState<Set<string>>(() => new Set());
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
const [expandedReplyActionForId, setExpandedReplyActionForId] = useState<number | null>(null);
@ -448,11 +458,11 @@ function MessageDialogPageContent() {
}, [cm, markRead, scroll]);
useEffect(() => {
if (cm.messagesLoading || cm.loadingOlder || displayMessages.length === 0) {
if (cmMessagesLoading || cmLoadingOlder || displayMessages.length === 0) {
return;
}
if (!cm.hasMoreOlder && !cm.allowOneOlderProbe) {
if (!cmHasMoreOlder && !cmAllowOneOlderProbe) {
return;
}
@ -471,7 +481,7 @@ function MessageDialogPageContent() {
const hasScrollableOverflow = viewport.scrollHeight > viewport.clientHeight + 8;
if (!hasScrollableOverflow) {
void cm.loadOlderMessages();
void cmLoadOlderMessages();
}
});
@ -480,11 +490,11 @@ function MessageDialogPageContent() {
window.cancelAnimationFrame(frameId);
};
}, [
cm.allowOneOlderProbe,
cm.hasMoreOlder,
cm.loadingOlder,
cm.loadOlderMessages,
cm.messagesLoading,
cmAllowOneOlderProbe,
cmHasMoreOlder,
cmLoadOlderMessages,
cmLoadingOlder,
cmMessagesLoading,
displayMessages.length,
]);
@ -547,6 +557,7 @@ function MessageDialogPageContent() {
: `local-${Date.now()}-${Math.random().toString(16).slice(2)}`;
optimisticLocalId = localId;
const opt = cm.buildOptimistic(text, files, localId, replyToId);
if (files.length > 0) pendingRetryFilesRef.current.set(localId, files);
cm.addPending(opt);
cm.schedulePendingTimer(localId);
resetComposerState();
@ -556,6 +567,7 @@ function MessageDialogPageContent() {
: await apiClient.sendMessage(chatId, text, replyToId);
cm.removePendingMessage(localId);
pendingRetryFilesRef.current.delete(localId);
cm.setMessages(prev => uniqueMessagesById([...prev, sent]));
if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false));
@ -582,6 +594,47 @@ function MessageDialogPageContent() {
const removeAttachedFile = (index: number) => setAttachedFiles(prev => prev.filter((_, i) => i !== index));
const handlePickFiles = () => { if (!editingMessageId) fileInputRef.current?.click(); };
const canRetryMessage = useCallback((message: Message): boolean => {
if (message.send_state !== "failed" || !message.local_id) return false;
const hasAttachments = Boolean(message.attachments?.length);
return !hasAttachments || pendingRetryFilesRef.current.has(message.local_id);
}, []);
const handleRetryMessage = useCallback(async (message: Message) => {
const localId = message.local_id;
if (!chatId || !localId || retryingLocalIds.has(localId) || !canRetryMessage(message)) return;
const text = (message.content || "").trim();
const files = pendingRetryFilesRef.current.get(localId) || [];
const replyToId = getReplyToId(message) ?? undefined;
const wasNearBottom = scroll.isNearBottom();
setRetryingLocalIds(prev => new Set(prev).add(localId));
cm.markPendingSending(localId);
cm.schedulePendingTimer(localId);
try {
const sent = files.length
? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId)
: await apiClient.sendMessage(chatId, text, replyToId);
cm.removePendingMessage(localId);
pendingRetryFilesRef.current.delete(localId);
cm.setMessages(prev => uniqueMessagesById([...prev, sent]));
if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false));
} catch (e) {
console.error("Ошибка переотправки сообщения:", e);
cm.markPendingFailed(localId);
} finally {
setRetryingLocalIds(prev => {
const next = new Set(prev);
next.delete(localId);
return next;
});
}
}, [canRetryMessage, chatId, cm, retryingLocalIds, scroll]);
/* ── message actions ── */
const canManageMessage = (message: Message): boolean => {
@ -738,7 +791,7 @@ function MessageDialogPageContent() {
let cancelled = false;
void (async () => {
const jumped = await cm.jumpToMessage(linkedMessageId);
const jumped = await cmJumpToMessage(linkedMessageId);
if (cancelled) {
return;
}
@ -751,7 +804,7 @@ function MessageDialogPageContent() {
return () => {
cancelled = true;
};
}, [clearMessageParam, cm.jumpToMessage, highlightFoundMessage, linkedMessageId]);
}, [clearMessageParam, cmJumpToMessage, highlightFoundMessage, linkedMessageId]);
const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => {
const normalizedQuery = query.trim();
@ -1114,6 +1167,9 @@ function MessageDialogPageContent() {
onQuickReact={handleReact}
onOpenReactionPicker={(msg) => setReactionPickerForMessageId(msg.id)}
onShowAllReaders={() => setIsReadersModalOpen(true)}
onRetry={handleRetryMessage}
isRetrying={Boolean(message.local_id && retryingLocalIds.has(message.local_id))}
retryDisabled={!canRetryMessage(message)}
onReply={handleReplyToMessage}
onEdit={handleStartEditMessage}
onDelete={handleDeleteMessage}

View File

@ -6,6 +6,7 @@ import {
ArrowUp,
Bell,
Check,
Copy,
Clock3,
Download,
Eye,
@ -15,6 +16,7 @@ import {
Mail,
Monitor,
Moon,
QrCode,
Save,
Smartphone,
Sun,
@ -149,6 +151,8 @@ export default function SettingsPage() {
profileDirty,
profileForm,
pushLoading,
qrLogin,
qrLoginLoading,
resolvedTheme,
savingContacts,
savingPreferences,
@ -166,6 +170,7 @@ export default function SettingsPage() {
handleChangePassword,
handleDndToggle,
handleLogoutOthers,
handleCreateQrLogin,
handlePushToggle,
handleSessionLogout,
logout,
@ -790,6 +795,60 @@ export default function SettingsPage() {
</div>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<QrCode size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">Вход по QR</p>
<p className="app-text-muted mt-1 text-sm">
Одноразовая ссылка для авторизации нового устройства.
</p>
</div>
</div>
<button
type="button"
onClick={() => void handleCreateQrLogin()}
disabled={qrLoginLoading}
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"
>
<QrCode size={16} />
{qrLoginLoading ? "Создаем..." : qrLogin ? "Обновить QR" : "Создать QR"}
</button>
</div>
{qrLogin ? (
<div className="mt-4 grid gap-4 md:grid-cols-[auto,minmax(0,1fr)]">
<div className="w-fit rounded-2xl bg-white p-3">
<Image
src={qrLogin.qrDataUrl}
alt="QR для входа"
width={176}
height={176}
unoptimized
/>
</div>
<div className="min-w-0">
<p className="app-text-muted text-sm">
Действует до {formatSessionDateTime(qrLogin.expiresAt)}.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void navigator.clipboard?.writeText(qrLogin.loginUrl)}
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
>
<Copy size={16} />
Скопировать ссылку
</button>
</div>
</div>
</div>
) : null}
</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">

View File

@ -12,6 +12,7 @@ import {
Image as ImageIcon,
Loader2,
PlayCircle,
RotateCcw,
} from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
@ -73,6 +74,9 @@ type ChatMessageItemProps = {
onQuickReact: (message: Message, emoji: string) => void;
onOpenReactionPicker: (message: Message) => void;
onShowAllReaders?: (message: Message) => void;
onRetry?: (message: Message) => void;
isRetrying?: boolean;
retryDisabled?: boolean;
onReply: (message: Message) => void;
onEdit: (message: Message) => void;
onDelete: (message: Message) => void;
@ -149,6 +153,9 @@ export default function ChatMessageItem({
onQuickReact,
onOpenReactionPicker,
onShowAllReaders,
onRetry,
isRetrying = false,
retryDisabled = false,
onReply,
onEdit,
onDelete,
@ -448,6 +455,18 @@ export default function ChatMessageItem({
) : null}
<div className={`mt-1 flex items-center ${isMine ? "justify-end gap-1.5" : "justify-end"}`}>
{isMine && sendState === "failed" && onRetry ? (
<button
type="button"
onClick={() => onRetry(message)}
disabled={isRetrying || retryDisabled}
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/15 text-white transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/35 disabled:cursor-not-allowed disabled:opacity-45"
title={retryDisabled ? "Вложения нужно выбрать заново" : isRetrying ? "Отправляется" : "Переотправить"}
aria-label={retryDisabled ? "Нельзя переотправить: вложения нужно выбрать заново" : "Переотправить сообщение"}
>
<RotateCcw size={12} className={isRetrying ? "animate-spin" : ""} />
</button>
) : null}
<p className={`text-right text-[11px] ${isMine ? "text-sky-100" : "app-text-muted"}`}>{messageTime(message)}</p>
{isMine ? <MessageDeliveryStatus sendState={sendState} isRead={isRead} /> : null}
</div>

View File

@ -36,9 +36,10 @@ export function UserProvider({ children }: { children: ReactNode }) {
// ApiClient автоматически обновит токен если он истёк
const userData = await apiClient.getCurrentUser();
setUser(userData);
} catch (err: any) {
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error('Ошибка загрузки пользователя:', err);
setError(err.message);
setError(message);
setUser(null);
// Токены уже очищены в ApiClient если refresh token тоже истёк
@ -46,7 +47,10 @@ export function UserProvider({ children }: { children: ReactNode }) {
const hasToken = typeof window !== 'undefined' && localStorage.getItem('access_token');
if (!hasToken) {
// Токены очищены - сессия истекла, redirect на login
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
if (
typeof window !== 'undefined' &&
!['/login', '/login/qr', '/login/qr/confirm'].includes(window.location.pathname)
) {
window.location.href = '/login';
}
}

View File

@ -3,6 +3,12 @@ import { apiClient } from "@/lib/api";
import type { Chat, Message } from "@/types/api";
import { getMessageTimestamp, uniqueMessagesById } from "@/lib/messages/chatUtils";
import { getReplyToId } from "@/lib/messages/messageUtils";
import {
getPendingMessageStorageIdentity,
loadStoredPendingMessages,
reconcileStoredPendingMessages,
saveStoredPendingMessages,
} from "@/lib/messages/pendingMessageStorage";
const PENDING_MESSAGE_DELAY_MS = 8000;
@ -62,6 +68,8 @@ export interface UseChatMessagesReturn {
schedulePendingTimer: (localId: string) => void;
/** Mark a pending message as failed */
markPendingFailed: (localId: string) => void;
/** Mark a failed pending message as sending again */
markPendingSending: (localId: string) => void;
/** Jump to a specific date (history navigation) */
jumpToDate: (dateISO: string) => Promise<void>;
/** Return to unread boundary (around last_read_message_id) */
@ -124,6 +132,7 @@ export function useChatMessages({
const hasMoreNewerRef = useRef(false);
const initialScrolledRef = useRef(false);
const pendingMessagesRef = useRef<Message[]>([]);
const pendingStorageIdentityRef = useRef<string | null>(null);
const localSeqRef = useRef(0);
const pendingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const fallbackInFlightRef = useRef(false);
@ -135,6 +144,13 @@ export function useChatMessages({
/* keep refs in sync */
useEffect(() => { hasMoreNewerRef.current = hasMoreNewer; }, [hasMoreNewer]);
useEffect(() => { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]);
useEffect(() => {
if (!chatId || Number.isNaN(chatId) || !userId) return;
const identity = getPendingMessageStorageIdentity(chatId, userId);
if (pendingStorageIdentityRef.current !== identity) return;
saveStoredPendingMessages(chatId, userId, pendingMessages);
}, [chatId, pendingMessages, userId]);
/* ── pending message helpers ── */
@ -172,6 +188,10 @@ export function useChatMessages({
setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "failed" } : m));
}, [clearPendingTimer]);
const markPendingSending = useCallback((localId: string) => {
setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "pending" } : m));
}, []);
/* ── attachment signature matching ── */
const getAttachmentSig = useCallback((msg: Message) =>
@ -293,6 +313,7 @@ export function useChatMessages({
useEffect(() => {
if (!chatId || Number.isNaN(chatId)) {
pendingStorageIdentityRef.current = null;
clearAllPendingTimers(); setMessages([]); setPendingMessages([]);
setHasMoreOlder(false); setHasMoreNewer(false);
setInitialAnchorId(null); setInitialAnchorIndex(null);
@ -301,9 +322,14 @@ export function useChatMessages({
resetNewMessagesBelow();
return;
}
if (userLoading || !userId) return;
if (userLoading || !userId) {
pendingStorageIdentityRef.current = null;
return;
}
let cancelled = false;
const storageIdentity = getPendingMessageStorageIdentity(chatId, userId);
pendingStorageIdentityRef.current = null;
(async () => {
try {
@ -323,8 +349,12 @@ export function useChatMessages({
if (aroundMsgs.length > 0) {
clearAllPendingTimers();
const norm = uniqueMessagesById(aroundMsgs);
const restoredPending = reconcileStoredPendingMessages(
loadStoredPendingMessages(chatId, userId),
norm,
);
setMessages(norm);
setPendingMessages([]);
setPendingMessages(restoredPending);
setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50);
setHasMoreNewer(Boolean(around.has_more_after));
setInitialAnchorId(around.anchor_id ?? null);
@ -344,7 +374,11 @@ export function useChatMessages({
const resp = await apiClient.getChatMessages(chatId, { limit: 50 });
if (cancelled) return;
const norm = uniqueMessagesById(resp.messages || []);
setMessages(norm); setPendingMessages([]);
const restoredPending = reconcileStoredPendingMessages(
loadStoredPendingMessages(chatId, userId),
norm,
);
setMessages(norm); setPendingMessages(restoredPending);
setHasMoreOlder(Boolean(resp.has_more)); setHasMoreNewer(false);
setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false);
unreadBelowIdsRef.current = new Set(
@ -356,6 +390,7 @@ export function useChatMessages({
setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size));
}
pendingStorageIdentityRef.current = storageIdentity;
initialScrolledRef.current = false;
setAnchorRetryTick(0);
} catch (err: unknown) {
@ -416,7 +451,7 @@ export function useChatMessages({
const newest = messages[messages.length - 1];
try {
setLoadingNewer(true);
const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: false });
const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: opts?.markRead ?? false });
const newer = resp.messages || [];
if (newer.length > 0) {
newer.forEach(removePendingByServer);
@ -605,6 +640,7 @@ export function useChatMessages({
addPending,
schedulePendingTimer,
markPendingFailed,
markPendingSending,
jumpToDate,
returnToUnread,
jumpToMessage,

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import QRCode from "qrcode";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@ -151,6 +152,12 @@ export function useSettingsPage() {
const [sessionsLoading, setSessionsLoading] = useState(true);
const [sessionsUnavailable, setSessionsUnavailable] = useState(false);
const [sessionActionKey, setSessionActionKey] = useState<string | null>(null);
const [qrLogin, setQrLogin] = useState<{
loginUrl: string;
qrDataUrl: string;
expiresAt: string;
} | null>(null);
const [qrLoginLoading, setQrLoginLoading] = useState(false);
const [passwordForm, setPasswordForm] = useState({
current_password: "",
new_password: "",
@ -513,6 +520,34 @@ export function useSettingsPage() {
}
};
const handleCreateQrLogin = async () => {
try {
setQrLoginLoading(true);
const response = await apiClient.createQrLogin();
const loginUrl = `${window.location.origin}/login/qr?token=${encodeURIComponent(response.token)}`;
const qrDataUrl = await QRCode.toDataURL(loginUrl, {
errorCorrectionLevel: "M",
margin: 1,
width: 224,
color: {
dark: "#111827",
light: "#ffffff",
},
});
setQrLogin({
loginUrl,
qrDataUrl,
expiresAt: response.expires_at,
});
toast.success("QR-вход создан");
} catch (error) {
console.error(error);
toast.error("Не удалось создать QR для входа");
} finally {
setQrLoginLoading(false);
}
};
const handleChangePassword = async () => {
if (
!passwordForm.current_password ||
@ -567,6 +602,8 @@ export function useSettingsPage() {
profileDirty,
profileForm,
pushLoading,
qrLogin,
qrLoginLoading,
resolvedTheme,
savingContacts,
savingPreferences,
@ -585,6 +622,7 @@ export function useSettingsPage() {
handleChangePassword,
handleDndToggle,
handleLogoutOthers,
handleCreateQrLogin,
handlePushToggle,
handleSessionLogout,
logout,

View File

@ -6,6 +6,9 @@ import type {
PasswordResetConfirmResult,
PasswordResetRequestPayload,
PasswordResetRequestResult,
QrLoginCreateResult,
QrLoginRequestActionResult,
QrLoginRequestDetailResult,
SessionBulkActionResult,
} from "@/types/api";
@ -18,6 +21,24 @@ export function createAuthApi(request: RequestFn) {
request(`/api/auth/sessions/${sessionId}/`, { method: "DELETE" }),
logoutOtherSessions: (): Promise<SessionBulkActionResult> =>
request("/api/auth/sessions/logout-others/", { method: "POST" }),
createQrLogin: (): Promise<QrLoginCreateResult> =>
request("/api/auth/qr-login/", { method: "POST" }),
getQrLoginRequest: (
scanToken: string,
): Promise<QrLoginRequestDetailResult> =>
request(`/api/auth/qr-login/requests/${encodeURIComponent(scanToken)}/`),
approveQrLoginRequest: (
scanToken: string,
): Promise<QrLoginRequestActionResult> =>
request(`/api/auth/qr-login/requests/${encodeURIComponent(scanToken)}/approve/`, {
method: "POST",
}),
denyQrLoginRequest: (
scanToken: string,
): Promise<QrLoginRequestActionResult> =>
request(`/api/auth/qr-login/requests/${encodeURIComponent(scanToken)}/deny/`, {
method: "POST",
}),
changePassword: (
payload: ChangePasswordPayload,
): Promise<ChangePasswordResult> =>

View File

@ -3,17 +3,18 @@
* Domain-specific methods live in separate modules.
*/
import type {
LoginResponse,
QrLoginRequestCreateResult,
QrLoginRequestStatusResult,
} from "@/types/api";
interface LoginCredentials {
email?: string;
phone?: string;
password: string;
}
interface LoginResponse {
access: string;
refresh: string;
}
export class ApiClientBase {
private tokenKey = 'access_token';
private refreshTokenKey = 'refresh_token';
@ -167,6 +168,52 @@ export class ApiClientBase {
this.setRefreshToken(data.refresh);
}
async loginWithQrToken(token: string): Promise<void> {
const response = await fetch('/api/auth/qr-login/exchange/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) throw new Error(`QR login failed: ${response.status}`);
const data: LoginResponse = await response.json();
this.setToken(data.access);
this.setRefreshToken(data.refresh);
}
async createQrLoginRequest(): Promise<QrLoginRequestCreateResult> {
const response = await fetch('/api/auth/qr-login/requests/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) throw new Error(`QR request failed: ${response.status}`);
return response.json();
}
async pollQrLoginRequest(clientSecret: string): Promise<QrLoginRequestStatusResult> {
const response = await fetch('/api/auth/qr-login/requests/status/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_secret: clientSecret }),
});
if (!response.ok) throw new Error(`QR status failed: ${response.status}`);
const data: QrLoginRequestStatusResult = await response.json();
if (data.status === 'approved' && data.access && data.refresh) {
this.setToken(data.access);
this.setRefreshToken(data.refresh);
}
return data;
}
async cancelQrLoginRequest(clientSecret: string): Promise<void> {
const response = await fetch('/api/auth/qr-login/requests/cancel/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_secret: clientSecret }),
});
if (!response.ok) throw new Error(`QR cancel failed: ${response.status}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async getCurrentUser(): Promise<any> {
return this.request('/api/v1/employees/me/');
@ -194,7 +241,7 @@ export class ApiClientBase {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (Array.isArray(data[key])) {
data[key].forEach((value: any) => {
data[key].forEach((value: unknown) => {
if (value !== null && value !== undefined) formData.append(key, String(value));
});
}

View File

@ -0,0 +1,179 @@
import type { Message, MessageAttachment } from "@/types/api";
import { getMessageTimestamp } from "@/lib/messages/chatUtils";
import { getReplyToId } from "@/lib/messages/messageUtils";
const STORAGE_PREFIX = "eusrr_chat_pending_messages:v1";
const STORAGE_VERSION = 1;
const MAX_STORED_MESSAGES = 50;
const PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
type StoredPendingPayload = {
version: typeof STORAGE_VERSION;
saved_at: number;
messages: Message[];
};
export function getPendingMessageStorageIdentity(chatId: number, userId: number): string {
return `${userId}:${chatId}`;
}
function getStorageKey(chatId: number, userId: number): string {
return `${STORAGE_PREFIX}:${getPendingMessageStorageIdentity(chatId, userId)}`;
}
function canUseLocalStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function sanitizeAttachment(attachment: MessageAttachment, index: number): MessageAttachment | null {
if (!attachment || typeof attachment !== "object") return null;
const fileName = typeof attachment.file_name === "string" ? attachment.file_name : "";
if (!fileName.trim()) return null;
return {
id: typeof attachment.id === "number" ? attachment.id : -(Date.now() * 100 + index),
file_name: fileName,
file_type: typeof attachment.file_type === "string" ? attachment.file_type : undefined,
file_url: typeof attachment.file_url === "string" ? attachment.file_url : "",
file_size: typeof attachment.file_size === "number" ? attachment.file_size : undefined,
mime_type: typeof attachment.mime_type === "string" ? attachment.mime_type : undefined,
width: typeof attachment.width === "number" ? attachment.width : undefined,
height: typeof attachment.height === "number" ? attachment.height : undefined,
thumbnail: typeof attachment.thumbnail === "string" ? attachment.thumbnail : null,
is_local: Boolean(attachment.is_local),
};
}
function normalizeSendState(sendState: Message["send_state"]): Message["send_state"] {
if (sendState === "pending" || sendState === "delayed" || sendState === "failed") {
return sendState;
}
return "failed";
}
function sanitizePendingMessage(message: Message): Message | null {
if (!message || typeof message !== "object") return null;
const localId = typeof message.local_id === "string" && message.local_id.trim() ? message.local_id : null;
if (!localId) return null;
const timestamp = getMessageTimestamp(message) || Date.now();
const attachments = (message.attachments || [])
.map((attachment, index) => sanitizeAttachment(attachment, index))
.filter((attachment): attachment is MessageAttachment => Boolean(attachment));
const content = typeof message.content === "string" ? message.content : "";
if (!content.trim() && attachments.length === 0) return null;
return {
id: typeof message.id === "number" && message.id < 0 ? message.id : -timestamp,
chat: typeof message.chat === "number" ? message.chat : undefined,
local_id: localId,
author_id: typeof message.author_id === "number" ? message.author_id : undefined,
author_name: typeof message.author_name === "string" ? message.author_name : undefined,
avatar: typeof message.avatar === "string" ? message.avatar : undefined,
content,
is_read: false,
send_state: normalizeSendState(message.send_state),
is_optimistic: true,
created_at: typeof message.created_at === "string" ? message.created_at : new Date(timestamp).toISOString(),
created_ts: timestamp,
has_attachments: attachments.length > 0,
attachments,
reply_to_id: getReplyToId(message) ?? undefined,
};
}
function pruneStoredMessages(messages: Message[], now = Date.now()): Message[] {
return messages
.map(sanitizePendingMessage)
.filter((message): message is Message => Boolean(message))
.filter((message) => now - getMessageTimestamp(message) <= PENDING_TTL_MS)
.slice(-MAX_STORED_MESSAGES);
}
export function loadStoredPendingMessages(chatId: number, userId: number): Message[] {
if (!canUseLocalStorage()) return [];
const key = getStorageKey(chatId, userId);
try {
const raw = window.localStorage.getItem(key);
if (!raw) return [];
const payload = JSON.parse(raw) as Partial<StoredPendingPayload>;
if (payload.version !== STORAGE_VERSION || !Array.isArray(payload.messages)) {
window.localStorage.removeItem(key);
return [];
}
const messages = pruneStoredMessages(payload.messages).map((message) => ({
...message,
send_state: "failed" as const,
}));
if (messages.length === 0) {
window.localStorage.removeItem(key);
}
return messages;
} catch {
window.localStorage.removeItem(key);
return [];
}
}
export function saveStoredPendingMessages(chatId: number, userId: number, messages: Message[]): void {
if (!canUseLocalStorage()) return;
const key = getStorageKey(chatId, userId);
const storedMessages = pruneStoredMessages(messages);
try {
if (storedMessages.length === 0) {
window.localStorage.removeItem(key);
return;
}
const payload: StoredPendingPayload = {
version: STORAGE_VERSION,
saved_at: Date.now(),
messages: storedMessages,
};
window.localStorage.setItem(key, JSON.stringify(payload));
} catch {
// Quota/security errors should not break the chat UI.
}
}
function getAttachmentSignature(message: Message): string {
return (message.attachments || [])
.map((attachment) => `${attachment.file_name}|${attachment.file_size ?? 0}|${attachment.mime_type ?? ""}`)
.sort()
.join("||");
}
function isConfirmedMatch(pending: Message, confirmed: Message): boolean {
if (confirmed.id <= 0 || confirmed.is_optimistic) return false;
if (pending.author_id && confirmed.author_id && pending.author_id !== confirmed.author_id) return false;
if ((pending.content || "") !== (confirmed.content || "")) return false;
if ((getReplyToId(pending) ?? null) !== (getReplyToId(confirmed) ?? null)) return false;
if (getAttachmentSignature(pending) !== getAttachmentSignature(confirmed)) return false;
const pendingTimestamp = getMessageTimestamp(pending);
const confirmedTimestamp = getMessageTimestamp(confirmed);
if (!pendingTimestamp || !confirmedTimestamp) return true;
return Math.abs(pendingTimestamp - confirmedTimestamp) <= 10 * 60 * 1000;
}
export function reconcileStoredPendingMessages(pendingMessages: Message[], serverMessages: Message[]): Message[] {
if (pendingMessages.length === 0 || serverMessages.length === 0) return pendingMessages;
return pendingMessages.filter(
(pending) => !serverMessages.some((serverMessage) => isConfirmedMatch(pending, serverMessage)),
);
}

View File

@ -97,6 +97,49 @@ export interface SessionBulkActionResult {
revoked: number;
}
export interface QrLoginCreateResult {
token: string;
expires_at: string;
}
export type QrLoginRequestStatus =
| "pending"
| "approved"
| "denied"
| "expired"
| "claimed";
export interface QrLoginRequestCreateResult {
scan_token: string;
client_secret: string;
expires_at: string;
device_name: string;
ip_address?: string | null;
}
export interface QrLoginRequestStatusResult {
status: QrLoginRequestStatus;
access?: string;
refresh?: string;
}
export interface QrLoginRequestDetailResult {
status: QrLoginRequestStatus;
device_name: string;
ip_address?: string | null;
created_at: string;
expires_at: string;
}
export interface QrLoginRequestActionResult {
status: QrLoginRequestStatus;
}
export interface LoginResponse {
access: string;
refresh: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;