feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет» #2577

Merged
lekss361 merged 1 commit from feat/tradein-anon-support into main 2026-07-31 13:22:21 +00:00
10 changed files with 604 additions and 36 deletions

View file

@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
`username` thread_id для отправки не нужен вообще, поэтому эту БД-операцию
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
отправка больше не создаёт тред.
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
-------------------------------------------------------
Ровно те же 4 действия, но БЕЗ авторизации доступны с экрана входа. Причина:
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
чат ЗА логином, а самая частая причина писать в поддержку как раз «не могу
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
успешных) и достучаться из продукта не могла ничем.
Идентичность анонима opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
делает коллизию с реальным логином структурно невозможной: `tradein_users`
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
двоеточия там быть не может аноним НИКОГДА не попадёт в чужой тред и не
«станет» существующим юзером.
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
thread_id не принимается снаружи ни в каком виде, тред резолвится
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь bearer-токен своего треда, поэтому
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
(144 бита) вместо чего-то угадываемого.
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer
зеркало топика читают люди и пересылают дальше.
"""
from __future__ import annotations
import hashlib
import logging
import re
import secrets
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramApiError, TelegramClient
@ -262,3 +290,204 @@ def mark_support_read(
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()
# ---------------------------------------------------------------------------
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
# ---------------------------------------------------------------------------
_ANON_COOKIE_NAME = "tradein_support_anon"
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
_ANON_THREAD_PREFIX = "anon:"
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
# авторизованной ветки нет. Два независимых бюджета:
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
# анонимные начинаются с "anon:", что невозможно для username);
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
_ANON_IP_RATE_LIMIT = 10
_ANON_IP_RATE_WINDOW_S = 600.0
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
def _anon_display_id(token: str) -> str:
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
sha256, а не префикс токена: префикс это часть bearer'а, а зеркало уходит
в Telegram-топик, который читают люди и пересылают дальше.
"""
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
def _read_anon_token(request: Request) -> str | None:
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
raw = request.cookies.get(_ANON_COOKIE_NAME)
if raw is None or not _ANON_TOKEN_RE.match(raw):
return None
return raw
def _anon_thread_key(token: str) -> str:
return f"{_ANON_THREAD_PREFIX}{token}"
def _set_anon_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_ANON_COOKIE_NAME,
value=token,
max_age=_ANON_COOKIE_MAX_AGE_S,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
@router.post("/support/anon/messages", response_model=SupportMessageOut)
async def send_anon_support_message(
payload: SupportMessageInput,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> SupportMessageOut:
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
Кука выставляется тоже только на успехе иначе первая же неудачная попытка
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
"""
if not _bot_configured():
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
token = _read_anon_token(request)
is_new_token = token is None
if token is None:
token = secrets.token_urlsafe(18)
thread_key = _anon_thread_key(token)
ip = _client_ip(request)
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте позже.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
display_id = _anon_display_id(token)
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_format_anon_mirror_text(display_id, payload.text),
message_thread_id=settings.telegram_support_topic_id or None,
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
logger.warning(
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
"ответ оператора на это сообщение не будет смаршрутизирован",
display_id,
)
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
if is_new_token:
_set_anon_cookie(response, token)
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
return SupportMessageOut(**row)
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
можно посмотреть историю, и самая вероятная причина написать как раз
невозможность войти (инцидент 2026-07-31).
"""
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
def list_anon_support_messages(
request: Request,
db: Annotated[Session, Depends(get_db)],
since: Annotated[int, Query(ge=0)] = 0,
) -> list[SupportMessageOut]:
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
Sync `def` (review M3) см. `list_support_messages`.
"""
token = _read_anon_token(request)
if token is None:
return []
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return []
rows = storage.list_messages(
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
)
return [SupportMessageOut(**r) for r in rows]
@router.get("/support/anon/unread", response_model=UnreadOut)
def get_anon_support_unread(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return UnreadOut(unread=0)
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return UnreadOut(unread=0)
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
@router.post("/support/anon/read", response_model=StatusOut)
def mark_anon_support_read(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return StatusOut()
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is not None:
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()

View file

@ -53,6 +53,15 @@ _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
# auth-required gate, не остальные защиты.
#
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
# логином.
_PUBLIC_PATHS = frozenset(
{
"/health",
@ -61,6 +70,12 @@ _PUBLIC_PATHS = frozenset(
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/logout",
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -73,6 +73,16 @@ def _build_test_app() -> FastAPI:
async def health() -> dict:
return {"status": "ok"}
# Анонимная поддержка (инцидент 2026-07-31) — публичная ветка рядом с
# авторизованной, чтобы тесты ниже проверяли ИМЕННО границу между ними.
@app.get("/api/v1/trade-in/support/anon/unread")
async def anon_support_unread() -> dict:
return {"unread": 0}
@app.get("/api/v1/trade-in/support/unread")
async def support_unread() -> dict:
return {"unread": 0}
return app
@ -253,6 +263,24 @@ def test_rbac_guard_skips_health(client: TestClient) -> None:
assert resp.json()["status"] == "ok"
def test_rbac_guard_lets_anon_support_through_without_identity(client: TestClient) -> None:
"""Инцидент 2026-07-31: поддержка должна работать БЕЗ входа — иначе тот, кто
не может залогиниться, не может и пожаловаться на это."""
resp = client.get("/api/v1/trade-in/support/anon/unread")
assert resp.status_code == 200
assert resp.json() == {"unread": 0}
def test_rbac_guard_still_gates_authenticated_support(client: TestClient) -> None:
"""Обратная сторона той же границы: анонимная ветка НЕ распахнула соседний
авторизованный support (тред залогиненного юзера по-прежнему за identity)."""
assert client.get("/api/v1/trade-in/support/unread").status_code == 401
resp = client.get(
"/api/v1/trade-in/support/unread", headers={"X-Authenticated-User": "nosuchuser"}
)
assert resp.status_code == 403
def test_rbac_guard_pilot_can_hit_non_admin_api(client: TestClient) -> None:
resp = client.get(
"/api/v1/me",

View file

@ -87,7 +87,11 @@ def client(db: MagicMock) -> TestClient:
yield db
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
# https, а не дефолтный http: анонимная ветка ставит идентити-куку с
# `secure=True` (как session-cookie), и по http httpx её не вернул бы в
# следующем запросе — тесты «тот же тред / тот же бюджет лимита» тихо
# проверяли бы каждый раз НОВОГО анонима. Прод и так только https.
return TestClient(app, base_url="https://testserver")
def _auth(username: str = "alice") -> dict[str, str]:
@ -417,17 +421,19 @@ def test_list_messages_returns_thread_scoped_rows(
monkeypatch.setattr(
support_module.storage,
"list_messages",
lambda db, *, thread_id, since_id, limit: [
{
"id": 1,
"direction": "in",
"text_body": "hi",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
]
if thread_id == 7
else [],
lambda db, *, thread_id, since_id, limit: (
[
{
"id": 1,
"direction": "in",
"text_body": "hi",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
]
if thread_id == 7
else []
),
)
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
assert r.status_code == 200
@ -534,3 +540,194 @@ def test_mark_read_calls_storage_when_thread_exists(
assert r.status_code == 200
assert mark_called == [7]
assert db.commit.called
# ── анонимная ветка: поддержка без входа (инцидент 2026-07-31) ────────────────
@pytest.fixture(autouse=True)
def _fresh_anon_ip_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Как `_fresh_rate_limiter`, но для per-IP бюджета анонимной ветки — иначе
состояние течёт между тестами в одном процессе pytest."""
monkeypatch.setattr(
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
)
def _patch_anon_storage(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Мокает storage для send-пути и возвращает список ключей тредов, с которыми
его позвали (проверяем, что аноним адресуется `anon:<token>`, а не логином)."""
seen_keys: list[str] = []
def fake_get_or_create(db: Any, username: str) -> int:
seen_keys.append(username)
return 1
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-31T00:00:00+00:00",
},
)
return seen_keys
def test_anon_send_without_any_auth_succeeds_and_sets_cookie(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Суть фичи: залогиниться нельзя, а написать в поддержку — можно."""
seen_keys = _patch_anon_storage(monkeypatch)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "не могу войти"})
assert r.status_code == 200, r.text
assert r.json()["text_body"] == "не могу войти"
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
assert token is not None
assert support_module._ANON_TOKEN_RE.match(token)
# Тред адресован анонимным ключом, не голым токеном и не чьим-то логином.
assert seen_keys == [f"anon:{token}"]
def test_anon_cookie_reused_across_messages_same_thread(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
seen_keys = _patch_anon_storage(monkeypatch)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "первое"})
token_after_first = client.cookies.get(support_module._ANON_COOKIE_NAME)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "второе"})
assert client.cookies.get(support_module._ANON_COOKIE_NAME) == token_after_first
assert seen_keys == [f"anon:{token_after_first}"] * 2
def test_anon_mirror_is_labelled_and_never_leaks_token(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Оператор видит, что это НЕзалогиненный посетитель, но bearer треда в
Telegram-топик не уходит (топик читают люди и пересылают дальше)."""
_patch_anon_storage(monkeypatch)
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "помогите"})
token = client.cookies.get(support_module._ANON_COOKIE_NAME)
sent_text = _fake_telegram_client.calls[-1]["text"]
assert sent_text.startswith("[С САЙТА · БЕЗ ВХОДА] anon-")
assert "помогите" in sent_text
assert token not in sent_text
assert support_module._anon_display_id(token) in sent_text
def test_anon_read_paths_without_cookie_are_empty_not_401(client: TestClient) -> None:
"""Виджет поллит эти ручки ДО первого сообщения — 401 там был бы ложной ошибкой."""
assert client.get("/api/v1/trade-in/support/anon/messages").status_code == 200
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
assert client.get("/api/v1/trade-in/support/anon/unread").json() == {"unread": 0}
assert client.post("/api/v1/trade-in/support/anon/read").json() == {"status": "ok"}
def test_anon_malformed_cookie_ignored_and_never_reaches_storage(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Кука клиент-контролируема: мусор из браузера не должен становиться ключом
треда. Считаем куку отсутствующей и выдаём новую."""
seen_keys = _patch_anon_storage(monkeypatch)
bogus = "not-a-valid-token!@#$%^"
client.cookies.set(support_module._ANON_COOKIE_NAME, bogus)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 200
assert len(seen_keys) == 1
assert bogus not in seen_keys[0]
assert seen_keys[0].startswith("anon:")
assert support_module._ANON_TOKEN_RE.match(seen_keys[0].removeprefix("anon:"))
def test_anon_read_path_with_malformed_cookie_returns_empty(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
find_calls = []
monkeypatch.setattr(
support_module.storage,
"find_thread_id",
lambda db, username: find_calls.append(username),
)
client.cookies.set(support_module._ANON_COOKIE_NAME, "!!not-a-token!!")
assert client.get("/api/v1/trade-in/support/anon/messages").json() == []
assert find_calls == [] # до storage мусор не доехал вообще
def test_anon_per_ip_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Главный анти-абуз: per-token бюджет обходится сбросом куки, per-IP — нет."""
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_anon_ip_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
)
# Ротация куки НЕ спасает — бюджет привязан к IP.
client.cookies.delete(support_module._ANON_COOKIE_NAME)
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"})
assert r.status_code == 429
assert "Retry-After" in r.headers
def test_anon_per_token_rate_limit_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_patch_anon_storage(monkeypatch)
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "1"}).status_code == 200
)
assert (
client.post("/api/v1/trade-in/support/anon/messages", json={"text": "2"}).status_code == 429
)
def test_anon_failed_send_sets_no_cookie_and_writes_nothing(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""Неудачная отправка не должна закреплять за посетителем пустой тред."""
seen_keys = _patch_anon_storage(monkeypatch)
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 502
assert seen_keys == []
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_bot_not_configured_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": "hi"})
assert r.status_code == 503
assert client.cookies.get(support_module._ANON_COOKIE_NAME) is None
def test_anon_blank_text_422(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/anon/messages", json={"text": " "})
assert r.status_code == 422
def test_anon_thread_key_cannot_collide_with_real_username() -> None:
"""Инвариант изоляции: `anon:` невозможен в реальном логине (CHECK миграции
193 + Pydantic `^[A-Za-z0-9._-]{3,64}$`), значит аноним структурно не может
попасть в тред существующего пользователя."""
from app.schemas.team import _USERNAME_RE
key = support_module._anon_thread_key(support_module.secrets.token_urlsafe(18))
assert key.startswith("anon:")
assert _USERNAME_RE.match(key) is None

View file

@ -21,6 +21,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
interface LoginInput {
username: string;
@ -282,7 +283,23 @@ export default function LoginPage() {
"Войти"
)}
</button>
<p
style={{
margin: "16px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
lineHeight: 1.5,
}}
>
Не получается войти? Напишите нам кнопка «Поддержка» в правом нижнем
углу. Отвечаем без входа в систему.
</p>
</form>
{/* Инцидент 2026-07-31: без этого тот, кто не может залогиниться, не
может и сообщить об этом единственный чат был за логином. */}
<AnonSupportWidget />
</main>
);
}

View file

@ -0,0 +1,32 @@
"use client";
/**
* Чат поддержки для экранов БЕЗ входа экран логина и «доступа нет».
*
* WHY (инцидент 2026-07-31): после cutover'а на свою авторизацию (#2558)
* единственным каналом в поддержку был чат ЗА логином, а самая частая причина
* писать в поддержку как раз «не могу войти». В тот день «Практика» весь день
* билась в форму входа (5 неудачных попыток с трёх разных IP, ни одной успешной)
* и достучаться до нас из продукта не могла ничем: на `/login` не было ни чата,
* ни контакта.
*
* Технически это тот же `SupportButton` + `SupportChatPanel`, что и в `/v2`,
* только в анонимном scope (`SupportScope = "anon"`, ручки `/support/anon/*`)
* тред резолвится из httpOnly-куки, а не из идентити. Провайдер здесь свой:
* `SupportChatProvider` живёт в `app/v2/layout.tsx`, куда эти экраны не входят.
*
* Отдельный компонент, а не копипаста в двух местах: точек монтирования две
* (login-страница и `NoAccessScreen`), и обе тупики, из которых пользователю
* больше некуда идти.
*/
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
export function AnonSupportWidget() {
return (
<SupportChatProvider>
<SupportButton scope="anon" />
</SupportChatProvider>
);
}

View file

@ -7,9 +7,15 @@
*
* Fullscreen «доступа нет» для 403 от /me или для denied path.
* Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css).
*
* СОЗНАТЕЛЬНОЕ расхождение с зеркалом (2026-07-31): здесь внизу монтируется
* `AnonSupportWidget`, в копии Site Finder'а его нет и быть не может виджет
* ходит в trade-in'овый support-бридж (`/api/v1/trade-in/support/anon/*`),
* которого в том бэкенде не существует. Всё остальное держим в синхроне.
*/
import { logout } from "@/lib/logout";
import { AnonSupportWidget } from "@/components/auth/AnonSupportWidget";
interface NoAccessScreenProps {
variant: "user" | "path" | "session" | "trial" | "error";
@ -168,6 +174,12 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
</button>
)}
</div>
{/* Второй тупик, из которого пользователю некуда идти (первый /login).
Анонимный scope, а не авторизованный: на variant="session"/"error"
идентити уже нет, а разное поведение чата на соседних вариантах одного
экрана лишняя развилка на ровном месте. Инцидент 2026-07-31. */}
<AnonSupportWidget />
</main>
);
}

View file

@ -32,6 +32,7 @@ import { tokens } from "./tokens";
import { useSupportChat } from "./SupportChatContext";
import { SupportChatPanel } from "./SupportChatPanel";
import { useSupportUnread } from "@/lib/useSupportChat";
import type { SupportScope } from "@/lib/useSupportChat";
const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
@ -45,7 +46,16 @@ const styles = `
}
`;
export function SupportButton() {
interface SupportButtonProps {
/**
* "anon" экран входа / «доступа нет», где идентити нет и быть не может.
* Дефолт "auth" все существующие места монтирования (v2 layout) не меняются.
* См. `SupportScope` в `@/lib/useSupportChat`.
*/
scope?: SupportScope;
}
export function SupportButton({ scope = "auth" }: SupportButtonProps = {}) {
// Portal-mount guard (SSR-safe): `document` only exists after mount
// (mirrors MapPicker.tsx:107-108 / BuildingListingsDrawer.tsx:29-30).
const [mounted, setMounted] = useState(false);
@ -54,7 +64,7 @@ export function SupportButton() {
const { open, toggleChat, closeChat } = useSupportChat();
// Unread badge only matters while the panel is closed — see
// useSupportUnread's docstring for why polling stops entirely once open.
const unreadQuery = useSupportUnread(!open);
const unreadQuery = useSupportUnread(!open, scope);
const unread = unreadQuery.data?.unread ?? 0;
if (!mounted) return null;
@ -139,7 +149,7 @@ export function SupportButton() {
</span>
)}
</button>
<SupportChatPanel open={open} onClose={closeChat} />
<SupportChatPanel open={open} onClose={closeChat} scope={scope} />
</>,
document.body,
);

View file

@ -24,11 +24,13 @@ import {
useSendSupportMessage,
useSupportMessages,
} from "@/lib/useSupportChat";
import type { SupportMessage } from "@/lib/useSupportChat";
import type { SupportMessage, SupportScope } from "@/lib/useSupportChat";
interface SupportChatPanelProps {
open: boolean;
onClose: () => void;
/** См. `SupportScope` — "anon" для экрана входа / «доступа нет». */
scope?: SupportScope;
}
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
@ -54,7 +56,11 @@ const PANEL_STYLES = `
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
`;
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
export function SupportChatPanel({
open,
onClose,
scope = "auth",
}: SupportChatPanelProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const listEndRef = useRef<HTMLDivElement>(null);
@ -64,9 +70,9 @@ export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
const [draft, setDraft] = useState("");
const messagesQuery = useSupportMessages(open);
const sendMessage = useSendSupportMessage();
const markRead = useMarkSupportRead();
const messagesQuery = useSupportMessages(open, scope);
const sendMessage = useSendSupportMessage(scope);
const markRead = useMarkSupportRead(scope);
// Mark the thread read on BOTH the closed->open and open->closed edges (the
// effect fires on mount-while-open and its cleanup fires on the reverse

View file

@ -39,6 +39,21 @@ import { apiFetch, HTTPError } from "@/lib/api";
const BASE = "/api/v1/trade-in/support";
/**
* Какой поддержкой пользуемся:
* "auth" тред залогиненного юзера, резолвится сервером из сессии/идентити;
* "anon" тред посетителя БЕЗ входа (экран логина, «доступа нет»), резолвится
* из httpOnly-куки, которую ставит бэкенд (`/support/anon/*`).
*
* Появилось после инцидента 2026-07-31: единственный канал в поддержку был ЗА
* логином, а типовая причина писать «не могу войти».
*/
export type SupportScope = "auth" | "anon";
function scopeBase(scope: SupportScope): string {
return scope === "anon" ? `${BASE}/anon` : BASE;
}
// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
// client-side too so the send button disables before the round-trip 422/400.
export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
@ -57,8 +72,12 @@ export interface SupportUnread {
unread: number;
}
const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
// Scope — часть ключа: анонимный и авторизованный треды физически разные, общий
// ключ склеил бы их в кэше (после логина в панели висела бы переписка анонима).
const messagesKey = (scope: SupportScope) =>
["trade-in", "support", scope, "messages"] as const;
const unreadKey = (scope: SupportScope) =>
["trade-in", "support", scope, "unread"] as const;
const MESSAGES_POLL_MS = 6_000;
const UNREAD_POLL_MS = 20_000;
@ -67,10 +86,11 @@ const UNREAD_POLL_MS = 20_000;
* Polls the caller's own thread. `enabled` should be the chat-panel `open`
* flag see module docstring.
*/
export function useSupportMessages(enabled: boolean) {
export function useSupportMessages(enabled: boolean, scope: SupportScope = "auth") {
return useQuery<SupportMessage[]>({
queryKey: SUPPORT_MESSAGES_KEY,
queryFn: () => apiFetch<SupportMessage[]>(`${BASE}/messages?since=0`),
queryKey: messagesKey(scope),
queryFn: () =>
apiFetch<SupportMessage[]>(`${scopeBase(scope)}/messages?since=0`),
enabled,
staleTime: 0,
refetchInterval: enabled ? MESSAGES_POLL_MS : false,
@ -81,26 +101,26 @@ export function useSupportMessages(enabled: boolean) {
* Feeds the unread badge on the closed floating button. `enabled` should be
* `!open` see module docstring.
*/
export function useSupportUnread(enabled: boolean) {
export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth") {
return useQuery<SupportUnread>({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
queryKey: unreadKey(scope),
queryFn: () => apiFetch<SupportUnread>(`${scopeBase(scope)}/unread`),
enabled,
staleTime: 0,
refetchInterval: enabled ? UNREAD_POLL_MS : false,
});
}
export function useSendSupportMessage() {
export function useSendSupportMessage(scope: SupportScope = "auth") {
const queryClient = useQueryClient();
return useMutation<SupportMessage, Error, string>({
mutationFn: (text) =>
apiFetch<SupportMessage>(`${BASE}/messages`, {
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
method: "POST",
body: JSON.stringify({ text }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
},
});
}
@ -111,14 +131,16 @@ export function useSendSupportMessage() {
* unread badge never counts messages the visitor demonstrably already saw
* while the panel was open.
*/
export function useMarkSupportRead() {
export function useMarkSupportRead(scope: SupportScope = "auth") {
const queryClient = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: async () => {
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
await apiFetch<{ status: string }>(`${scopeBase(scope)}/read`, {
method: "POST",
});
},
onSuccess: () => {
queryClient.setQueryData<SupportUnread>(SUPPORT_UNREAD_KEY, { unread: 0 });
queryClient.setQueryData<SupportUnread>(unreadKey(scope), { unread: 0 });
},
});
}