Update auth flows and chat pending messages
This commit is contained in:
parent
653e75ad8a
commit
2b8e02e1f7
@ -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"
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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/",
|
||||
|
||||
@ -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"],
|
||||
|
||||
131
backend/api/migrations/0003_qrlogintoken.py
Normal file
131
backend/api/migrations/0003_qrlogintoken.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
164
backend/api/migrations/0004_qrloginrequest.py
Normal file
164
backend/api/migrations/0004_qrloginrequest.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,3 @@
|
||||
from api.auth.models import UserAuthSession
|
||||
from api.auth.models import QrLoginRequest, QrLoginToken, UserAuthSession
|
||||
|
||||
__all__ = ["UserAuthSession"]
|
||||
__all__ = ["QrLoginRequest", "QrLoginToken", "UserAuthSession"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
142
backend/tests/api/v1/notifications/test_push_sender.py
Normal file
142
backend/tests/api/v1/notifications/test_push_sender.py
Normal 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) + "..."
|
||||
@ -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>
|
||||
|
||||
281
frontend/src/app/login/qr/confirm/page.tsx
Normal file
281
frontend/src/app/login/qr/confirm/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/src/app/login/qr/page.tsx
Normal file
77
frontend/src/app/login/qr/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> =>
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
179
frontend/src/lib/messages/pendingMessageStorage.ts
Normal file
179
frontend/src/lib/messages/pendingMessageStorage.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user