Кнопка в карточке ноды открывает модалку: выбор маршрута (по умолчанию /
IP-адрес / интерфейс) с подсказками из данных ноды, ожидание, затем отчёт
картинкой либо тем же отчётом в JSON. Есть копирование JSON, скачивание
SVG, повтор проверки и полноэкранный режим.
Кнопка показывается только для узлов 3.3.0+ (versions.node) и только для
подключённых — иначе админ упирался бы в ошибку панели.
Отчёт вставляется как <img src="data:...">, а не сырым SVG в разметку: он
несёт встроенный моноширинный шрифт и <style>, на которых держится
выравнивание колонок, и санитайзер их выбрасывает. В <img> SVG рисуется в
изолированном контексте без скриптов и внешних запросов — и безопаснее, и
вернее по виду.
Зум и панорамирование — react-zoom-pan-pinch с той же конфигурацией, что в
самой панели Remnawave. Своя реализация обязательна: index.html задаёт
user-scalable=no, поэтому браузерного пинча нет ни в мобильном вебе, ни в
Mini App. Колесо намеренно не масштабирует, иначе прокрутка над отчётом
превращается в зум.
В отличие от панели стартовый масштаб считается от читаемой ширины: на
телефоне «вписано по ширине» превращает отчёт в серую рябь. На 390px это
2.42x с панорамированием, на десктопе — 1x. При смене ширины (полный
экран, поворот) масштаб пересчитывается.
Опрос задачи с потолком в три минуты — иначе забытая открытой модалка
опрашивала бы панель бесконечно. Пока проверка идёт, модалка не
закрывается: задача уже поставлена, терять её результат молча хуже.
Заодно в AdminRemnawave.tsx осели автоправки biome (import type,
Math.pow -> **) — файл всё равно правился, они механические.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
retrieveRawInitData() ищет параметры запуска по цепочке location.href ->
запись performance о навигации -> кэш tapps/launchParams в sessionStorage,
записывая удачный результат обратно. Последние два источника привязаны к
документу, а не к текущему запуску мини-аппы: как только SPA сменила
маршрут и hash с tgWebAppData ушёл из адреса, остаются данные ПЕРВОГО
запуска в этой сессии WebView. На iOS WebView переживает переоткрытия, и
SDK молча отдаёт старую копию.
Бэкенд принимает initData с auth_date не старше 30 суток, поэтому
застрявшая копия рано или поздно пересекает порог и вход отваливается с
«Invalid or expired Telegram authentication data».
Спрашиваем ещё и window.Telegram.WebApp.initData (официальный мост,
telegram-web-app.js из index.html) и берём вариант со свежим auth_date:
какой источник протух, зависит от платформы, «самый свежий» верен всегда.
Все три места чтения initData сведены в один модуль.
Лендинг поднимает Яндекс.Метрику с webvisor, а она пишет URL страницы и
запись сессии. Личный email или @username из ?contact= уезжал бы в
аналитику, в Referer при переходе на оплату, в историю браузера и в логи
сервера. Читаем параметр и сразу убираем его через history.replaceState,
сохраняя остальные параметры и хеш, — тем же приёмом, что применяет
captureCampaignFromUrl().
Чтение и очистка вынесены в src/utils/contactPrefill.ts: в компоненте это
были бы непокрытые строки, а обращение с персональными данными хочется
иметь под тестами.
Правка презентационная и покрытия не имела: класс break-words легко
потерять при переборе классов, а симптом невидим — контейнер сообщений
скроллится по горизонтали, но полосу прячет .scrollbar-hide.
Замер в браузере на ширине 1280px подтверждает цену потери: без
break-words контейнер 803px против содержимого 3179px, то есть 2376px
текста недостижимы. Класс проверяется рядом с самим выводом тела
сообщения во всех трёх рендерерах.
Покрытия у слага не было вовсе. Пиним два инварианта: слаг попадает в тело
запроса покупки и читается БЕЗ потребления — очистка на этом шаге лишила бы
привязки того же клиента, если он позже войдёт в кабинет.
Компоненты в репозитории не рендерятся (vitest на node, без jsdom), поэтому
отправка фиксируется по исходнику; поведение самих хелперов проверяется
обычными тестами с подменённым localStorage.
Тело сообщения рендерилось с whitespace-pre-wrap, но без break-words:
pre-wrap переносит только по пробелам, поэтому неразрывный токен —
ссылка на подписку, ключ, base64 — вылезал за пузырь. Контейнер
сообщений имеет scrollbar-hide и overflow-y-auto, из-за чего браузер
вычисляет overflow-x в auto, а полосу прокрутки прячет CSS: хвост такого
сообщения на десктопе было не достать.
Приведено к конвенции проекта для пользовательского текста
(BroadcastPreview.tsx): whitespace-pre-wrap break-words.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Покупка гостем на лендинге не проходит через auth-флоу, в котором кампания
привязывается обычно, поэтому клиент, пришедший по рекламной ссылке, не
попадал в статистику кампании и не получал её бонус.
Слаг уже лежит в localStorage после захода по рекламной ссылке
(captureCampaignFromUrl вызывается на любом маршруте) — теперь он уезжает в
запрос покупки. Читаем без потребления: гость может позже войти в кабинет, и
там привязка должна остаться возможной.
Требует поддержки campaign_slug в PurchaseRequest на стороне бота.
Дополнение к PR #548 при мерже. Код менять не пришлось — добавлены три теста.
Решение «это 409» автор вынес в утилиту и покрыл хорошо, а вот сама привязка
onError к мутациям осталась непокрытой — при том, что её отсутствие и есть
исходный баг: форма молчала, человек жал «Отправить» повторно. Проверил
мутациями: снятие onError у createMutation, снятие onError у replyMutation и
удаление обновления списка тикетов в ветке 409 набор не роняли.
Окружения для рендера компонентов в репозитории нет (vitest настроен на node,
без jsdom), поэтому закрепил структурно, по исходнику — приём тот же, что уже
используется в бот-репозитории. Крудовато, но ловит ровно ту регрессию, ради
которой PR и написан.
Остальное проверено, правок не потребовалось. Несущее допущение верно: 409 в
app/cabinet/routes/tickets.py встречается ровно один раз и именно про открытый
тикет, так что матч по статусу вместо текста detail — правильный выбор (текст
приходит только по-английски, а кабинет и бот версионируются раздельно).
Переименование rateLimitError → formError сквозное, хвостов не осталось; формы
create и reply взаимоисключающие, состояние сбрасывается на всех переходах.
Ключи support.errors.* есть во всех четырёх локалях. Шесть предупреждений biome
в Support.tsx — предсуществующие, столько же на чистом dev.
`POST /cabinet/tickets` отвечает 409 «You already have an open ticket»,
если у пользователя уже есть незакрытый тикет, но `createMutation` не имела
`onError` — форма молча оставалась на месте. Со стороны это выглядело как
«кнопка Отправить не работает»: пользователь жал её повторно и уходил в
поддержку с вопросом, почему обращение не создаётся. Тем же молчанием
отвечал и ответ в тикет (403 при блокировке в поддержке, 400 на закрытом
тикете).
Обе мутации теперь пишут текст ошибки в состояние формы — тот же блок,
в котором уже показывался клиентский rate-limit (`rateLimitError`
переименован в `formError`, состояние по смыслу общее). Для 409 показываем
локализованную подсказку и обновляем список тикетов: открытый тикет мог
появиться в другой сессии (бот, второе устройство), и после инвалидации
пользователю есть куда перейти. Остальные коды отдают `detail` бэка через
`getApiErrorMessage` с локализованным фолбэком.
409 ловим по статусу, а не по тексту `detail`: он приходит только
по-английски, а кабинет и бот версионируются раздельно.
Дополнения к PR #544 при мерже.
Плитка на главной берёт число подключённых устройств из запроса ['devices'],
а он выключен ровно там, где плитка живёт: `enabled: !!subscription &&
!isMultiTariff`, тогда как блок с плиткой рендерится в мультитарифной ветке,
где `subscription` всегда null (свой запрос отключён по !isMultiTariff).
То есть счётчик показывал бы «0 из N» всегда, лимит устройств не срабатывал бы
никогда, а точки-индикаторы стояли бы пустыми — при том, что счётчик и есть
смысл плитки.
Добавлен запрос по конкретной подписке: getDevices принимает subscription_id,
ключ ['devices', id] — тот же, что на странице подписки, поэтому кэш общий и
лишнего похода в сеть при переходе туда не будет. Заодно вынес выбор
единственной подписки в переменную, чтобы условие показа и данные плитки не
разъезжались.
Отдельно поправлен потерянный пробел в className: `duration-300${...}` склеивал
класс в `duration-300cursor-not-allowed`, из-за чего оформление заблокированной
плитки не применялось. Баг приехал вместе с переносом из SubscriptionCardActive
(там он с самого начала), но раз компонент всё равно выделяется — чиню здесь.
Дополнения к PR #543 при мерже.
С появлением трафика тип balance_and_days перестал означать «есть и баланс, и
дни»: теперь он стоит у любого набора с трафиком, в том числе с нулевым
балансом и нулём дней. Две поверхности этого не учитывали.
Форма редактирования поднимала галочки «Баланс» и «Дни» по ТИПУ. Код с одним
трафиком открывался с двумя чужими галочками на нулях, валидация требовала
«больше 0», и Сохранить не работало — админ не мог поправить даже число
активаций, ничего не меняя по сути. Хуже того, подсказка прямо предлагает
вписать сумму: сделав это, админ молча превращал код на 50 ГБ в код на 50 ГБ
плюс 100 рублей. Галочки теперь выводятся по значениям — ровно так, как сам
автор уже сделал для трафика строкой ниже.
Список промокодов печатал составляющие тоже по типу, поэтому код с одним
трафиком показывался как «+0 ₽ +0 дн.» и ни слова про гигабайты: админ при
разборе списка видел код, который якобы не даёт ничего. Теперь каждая
составляющая показывается по своему значению, и добавлена строка трафика —
ключ form.gb автор уже завёл во всех четырёх локалях.
Дробный ввод не трогал: поведение общее с полями баланса и дней и от этого PR
не зависит.
Дополнение к PR #542 при мерже.
Обработчик глушил ошибку целиком и показывал общее «Ошибка». Между тем сервер
отвечает ровно теми словами, которые админу и нужны: открытый временный доступ
(409, «сначала заверши или восстанови grace»), активная платная без force (409),
подписки нет (404). Первый случай автор сам описал в PR как штатный сценарий —
и именно он выглядел как сломанная кнопка. Прокинул getApiErrorMessage, он для
этого в репозитории и заведён и уже используется в восьми других местах.
Логику force не трогал: она совпадает с сервером. Сериализуемое is_active
собирается в _build_subscription_info как `status == active AND end_date > now`,
то есть в точности серверное свойство Subscription.is_active, по которому и
стоит защита. Разъехаться значения могут только в безопасную сторону: подписка
успела истечь между загрузкой страницы и нажатием — тогда защиты нет и на
сервере.
Дополнения к PR #539 при мерже.
Кнопка «Назад к входу через виджет» оставляла пустое место. Пока открыт
deep-link-экран, контейнер виджета размонтирован ранним return'ом, а эффект,
который вставляет скрипт Telegram, от этого не перезапускается: его
зависимости не меняются. Единственная зависимость, способная дрогнуть, —
handleScriptFailed через scriptLoaded, но на legacy-пути scriptLoaded не
выставляется никогда (только в OIDC-ветке). Так что при возврате контейнер
монтировался обратно уже пустым, и войти через виджет было нельзя до
перезагрузки страницы. Добавлен showDeepLinkUI в зависимости и ранний выход:
на входе в deep-link отрабатывает cleanup, на выходе скрипт вставляется заново.
Локали были только en и ru, а в кабинете их четыре. fallbackLng — 'ru',
поэтому персидские и китайские пользователи увидели бы русский текст на самом
первом экране. Именно этот класс регрессии описан в шапке locales.test.ts, и
поймать его тест не может: он сравнивает только en и ru. Добавлены fa и zh;
в zh термин «виджет» приведён к тому же 小部件, что уже используется в
telegramWidgetBlocked.
Дополнения к PR #534 при мерже.
Юнион sort_by в adminUsers.ts не знал про subscription_end_date. Ошибки
компиляции нет только потому, что вызывающий код собирает params как
Record<string, unknown> и приводит его каст-выражением, то есть юнион там
вообще не проверяется. Но это единственная запись о том, какие значения
принимает эндпоинт, и она разъехалась с реальностью.
Китайская строка «按到期» ставит после 按 глагол, тогда как все соседние
пункты — 按日期, 按余额, 按活跃度, 按消费 — существительные. Сам словарь
кабинета для этого поля использует 到期时间 (subscription.expiresAt,
admin.users.detail.expiresAt, promo.expiresIn), поэтому привёл к той же форме.
Русскую строку не трогал: «по истечению» здесь дательный падеж критерия, как
у соседей («по дате», «по балансу»), а не предложный «по истечении» из
временного оборота.
Подписку может выдать бонус рекламной кампании — она создаётся
автоматически, и человек попадает на главную с уже готовым доступом.
Плитка подключения жила только в карточке подписки на отдельной
странице, так что на главной ему нечем было воспользоваться: подписка
есть, а как подключить устройство — не видно.
Плитка вынесена из SubscriptionCardActive в отдельный компонент и
переиспользована обоими экранами, второй копии разметки не появилось.
На главной показывается, пока подписка одна: при нескольких выбор
устройства неоднозначен, и уместнее уходить в конкретную подписку.
Набор собирался из баланса, дней и промогруппы. Трафик добавляется
четвёртой галочкой: подписка у пользователя уже есть, и гигабайты
начисляются к ней без смены тарифа.
Включённый трафик переводит код в тип «набор бонусов» — на бэкенде
трафик живёт только там. Валидация требует положительный объём, как у
суммы и дней; при редактировании галочка поднимается сама, если у кода
уже задан трафик.
В мультитарифном режиме у пользователя несколько подписок, и убрать
лишнюю — например, отработавший триал — можно было только через экран
«Массовые действия». Во вкладке «Подписка» карточки подписки видны и
открываются, но удалить выбранную нечем.
Кнопка живёт в детальном виде подписки и требует подтверждения тем же
inline-механизмом, что и отмена автоплатежа; ключ подтверждения включает
id подписки, чтобы взведённое согласие не пережило переключение на
соседнюю. Активная платная подписка на сервере защищена от случайного
удаления, поэтому для неё запрос идёт с явным force — намерение админ
уже подтвердил.
- Drop the passive '@bot_username' link (opens the bot chat with no
auth purpose) for the common case — it's now redundant with the new
'Login via bot' button, which offers the same open-bot action plus
QR and actual authentication.
- Keep the referral deep link (bot start=<referral_code>) only when a
referralCode prop is present — that's a distinct registration flow
for not-yet-registered users, not a login method.
- Add an 'or' divider between the widget and the manual deep-link
button so the two remaining options read as equal alternatives
rather than a stack of similar-looking Telegram links.
- Shorten the deep-link button label to 'Login via bot' — the
no-phone-number framing is already implied and shown once the flow
starts.
No changes to the OIDC/widget button logic itself.
Currently the deep-link auth flow (t.me/{bot}?start=webauth_{token}) is
only triggered automatically as a fallback when the Telegram widget
script (oauth.telegram.org or telegram.org/js/telegram-widget.js) fails
to load. Users on unaffected networks have no way to choose this login
method even when they'd prefer confirming in the bot over typing a
phone number into the widget popup.
This adds a small 'Login via bot' link next to the existing widget,
reusing the exact same startDeepLinkAuth/poll logic already used by
the automatic fallback. No changes to the fallback behavior itself.
Remnawave 3.0.0 удалил `uuid` из модели пользователя — панельный аккаунт
адресуется числовым `id`, и бот переименовал соответствующие поля своего
API. Без этих правок вкладка Sync показывала бы «не привязан» у ВСЕХ
пользователей: оба операнда стали бы undefined, причём без ошибки TS —
интерфейсы продолжали бы декларировать несуществующие поля.
Типы: `UserDetailResponse.remnawave_uuid` → `remnawave_id: number | null`,
`PanelSyncStatusResponse.remnawave_uuid` → `remnawave_id`,
`SyncToPanelResponse.panel_uuid` → `panel_user_id`,
`PanelUserInfo.uuid` → `id: number` (бэкенд отдаёт его обязательным).
В SyncTab используется `??`, а не `||`: идентификатор числовой, и `0`
нужно отличать от отсутствия значения. Ярлык — «Remnawave ID».
Локаль: ключ настройки бота переименован в TRAFFIC_EXCLUDED_USER_IDS,
подпись обновлена — значения теперь числовые id, а не UUID.
Дерево разделов настроек — захардкоженный список категорий, а на бэкенде категория
выводится из имени ключа. Любая настройка, создавшая новую категорию, просто
исчезала из админки: настройки были в API, но ни один раздел их не показывал.
Так пропали 33 категории из 102, в том числе:
- CABINET целиком (13 настроек, включая CABINET_ENABLED, CABINET_URL,
CABINET_REQUIRE_LEGAL_CONSENT и CABINET_LEGAL_CONSENT_PRECHECKED);
- INFO_PAGES — режимы показа оферты, политики, правил и FAQ;
- MENU — кнопка меню Telegram с открытием кабинета.
Эти три разложены по дереву явно. Остальное собирает новый пункт «Прочие
настройки» — по образцу админки бота, где такой раздел уже есть. Это и есть
главная часть фикса: теперь новая категория на бэкенде не приводит к молчаливому
исчезновению настроек, максимум — к неудачному разделу.
Локали ru/en/zh/fa, +5 тестов на само дерево.
Новый пользователь попадал в кабинет молча — никаких документов ему не показывали.
Теперь при создании аккаунта он подтверждает, что ознакомился с офертой и политикой.
Набор документов и сам факт включения гейта приходят с бэка
(GET /cabinet/info/legal-consent): там же учитывается, заполнены ли документы и
видны ли они в вебе. Фронт знает только ключи и адреса публичных страниц /offer
и /privacy — тех же, что уже линкует футер логина.
Два пути входа устроены по-разному, поэтому и UI разный:
- регистрация по email — чекбоксы прямо в форме, кнопка заблокирована до галочек;
- Telegram-вход происходит сам собой, спрашивать заранее не у чего. Поэтому ловим
428 от бэка и показываем отдельный экран согласия, а замыкание помнит, какой
именно вход повторить после простановки галочек. Тот же приём страхует и
email-форму: конфиг мог протухнуть, если админ включил гейт между загрузкой
страницы и отправкой.
Неизвестный ключ документа всё равно рисуем (без ссылки): бэк его требует, и молча
спрятанный чекбокс превратился бы в «кнопка не работает и непонятно почему».
Локали ru/en/zh/fa.
Автоматическое ревью справедливо отметило загрузку html5-qrcode с CDN без
проверки целостности: подмена файла на стороне CDN исполнилась бы в кабинете
с полными правами страницы (доступ к сессии, initData Telegram).
Скрипт подключается с integrity (sha384 сверен по фактическому файлу
html5-qrcode@2.3.8), crossorigin=anonymous и referrerPolicy=no-referrer. При
несовпадении хеша браузер откажется исполнять скрипт, и сканер деградирует в
«камера недоступна» вместо запуска чужого кода.
Заодно закрыт тот же изъян в исходном месте, откуда пришёл паттерн:
TvQuickConnect грузил тот же файл с jsdelivr вообще без проверки. Он
переведён на общий хелпер (файлы на unpkg и jsdelivr побайтово идентичны —
сверил), дублирующийся загрузчик и интерфейс удалены.
Виджеты telegram.org намеренно оставлены без SRI: это самообновляемые
эндпоинты Telegram, фиксация хеша сломала бы логин при их обновлении.
Получателю раньше оставалось только скопировать ссылку или код руками.
- QR на экране готового подарка: кодируется bot-ссылка (активация в боте —
основной путь), при отсутствии username бота — ссылка кабинета. Использован
уже имеющийся qrcode.react, на белой подложке ради контраста в тёмной теме.
- Кнопка «Отсканировать QR» в форме активации. В Telegram используется
НАТИВНЫЙ сканер (в WebView камера через getUserMedia работает ненадёжно —
ровно то, из-за чего скан «в тг» и был проблемой), в вебе — html5-qrcode с
CDN, с фолбэком на фронтальную камеру и понятной ошибкой, если камеры нет.
- src/utils/qrScanner.ts: логика сканера вынесена из TvQuickConnect, чтобы её
мог переиспользовать любой экран. parseGiftCode понимает все три способа
распространения подарка (deep-link бота, ссылка кабинета, голый код с
префиксом GIFT- и без) и ОТКЛОНЯЕТ посторонние QR — иначе сканер подставил
бы в поле мусор с любой ссылки или Wi-Fi-кода.
- Веб-сканер гасится при уходе с вкладки и размонтировании, иначе индикатор
камеры остаётся гореть.
Локали ru/en/zh/fa, +4 теста на парсер.
Парная фронтовая часть к бэкенду (бот: 82609441).
- Поле «Лимит на пользователя» в форме создания партии: 0 — без
ограничения, для раздач и конкурсов ставится 1. Значение показывается в
карточке партии, если задано.
- Кнопка «Удалить партию» с подтверждением: отдельно от отзыва, который
лишь гасит ссылки. Если в партии есть погашенные купоны, подтверждение
предупреждает о потере истории активаций (выданные подписки не
отзываются). После удаления — возврат к списку.
- API: max_per_user в типах партии и запросе создания, deleteBatch.
Локали ru/en — раздел admin.coupons в zh/fa отсутствует целиком (купоны там
никогда не переводились), поэтому новые ключи добавлены в том же объёме.