diff --git a/package-lock.json b/package-lock.json index 18d0c41..760381b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "react-icons": "^5.6.0", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "react-zoom-pan-pinch": "^4.0.4", "recharts": "^3.7.0", "sigma": "^3.0.2", "simplex-noise": "^4.0.3", @@ -6211,6 +6212,20 @@ "react-dom": ">=19.0.0" } }, + "node_modules/react-zoom-pan-pinch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.4.tgz", + "integrity": "sha512-P0D7lfNHyJCNuUozoVdt0WNWcQ34ZbbD71B8pb+UtF7Th8MKBnxYiPApDfPZZmr+Gk0l0WmGTXL8R36JirHcQQ==", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index d53ba78..a8c9ae8 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "react-icons": "^5.6.0", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "react-zoom-pan-pinch": "^4.0.4", "recharts": "^3.7.0", "sigma": "^3.0.2", "simplex-noise": "^4.0.3", diff --git a/src/api/adminRemnawave.ts b/src/api/adminRemnawave.ts index 9e86819..4ecf105 100644 --- a/src/api/adminRemnawave.ts +++ b/src/api/adminRemnawave.ts @@ -112,6 +112,23 @@ export interface SystemStatsResponse { } // Nodes +export type NodeIpStatus = + | 'INBOUND' + | 'OUTBOUND' + | 'MANAGEMENT' + | 'TRANSIT' + | 'MONITORING' + | 'RESERVE' + | 'BLOCKED' + | 'FLAGGED' + | 'DEPRECATED' + | 'UNKNOWN'; + +export interface NodeIpAddress { + ip: string; + status: NodeIpStatus; +} + export interface NodeInfo { uuid: string; name: string; @@ -165,6 +182,8 @@ export interface NodeInfo { }; } | null; active_plugin_uuid?: string; + /** Адреса узла из панели — варианты исходного адреса для GeoCheck. */ + ips?: NodeIpAddress[]; config_profile?: { active_config_profile_uuid: string | null; active_inbounds: Array<{ @@ -206,6 +225,40 @@ export interface NodeActionResponse { is_disabled?: boolean; } +// GeoCheck (Remnawave 3.3.0) +/** С какого маршрута гнать проверку: пусто — маршрут узла по умолчанию. */ +export interface GeoCheckRequest { + ip?: string; + interface?: string; +} + +export interface GeoCheckStartResponse { + job_id: string; +} + +/** SVG-отчёт в base64, готовый для data: URL. */ +export interface GeoCheckImage { + format: string; + media_type: string; + encoding: string; + data: string; +} + +export interface GeoCheckResult { + success: boolean; + node_uuid?: string | null; + image?: GeoCheckImage | null; + raw_report?: Record | null; + message?: string | null; +} + +export interface GeoCheckJobResponse { + job_id: string; + is_completed: boolean; + is_failed: boolean; + result?: GeoCheckResult | null; +} + // Realtime Traffic export interface InboundTraffic { tag: string; @@ -403,6 +456,21 @@ export const adminRemnawaveApi = { return response.data; }, + /** Ставит GeoCheck ноды в очередь; результат забирается по job_id. */ + startNodeGeoCheck: async ( + uuid: string, + body: GeoCheckRequest = {}, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/geocheck`, body); + return response.data; + }, + + /** Статус задачи GeoCheck — нода отвечает до минуты. */ + getGeoCheckJob: async (jobId: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/remnawave/geocheck/${jobId}`); + return response.data; + }, + restartAllNodes: async (): Promise => { const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all'); return response.data; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index abd4863..f774774 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -456,7 +456,8 @@ export const adminUsersApi = { | 'traffic' | 'last_activity' | 'total_spent' - | 'purchase_count'; + | 'purchase_count' + | 'subscription_end_date'; } = {}, ): Promise => { const response = await apiClient.get('/cabinet/admin/users', { params }); @@ -518,6 +519,19 @@ export const adminUsersApi = { return response.data; }, + // Delete one of the user's subscriptions (multi-tariff: trials pile up) + deleteSubscription: async ( + userId: number, + subId: number, + force = false, + ): Promise<{ status: string }> => { + const response = await apiClient.delete( + `/cabinet/admin/users/${userId}/subscriptions/${subId}`, + { params: force ? { force: true } : undefined }, + ); + return response.data; + }, + // Update status updateStatus: async ( userId: number, diff --git a/src/api/client.ts b/src/api/client.ts index c643618..74600b6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { retrieveRawInitData } from '@telegram-apps/sdk-react'; +import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData'; import { tokenStorage, isTokenExpired, @@ -41,13 +41,11 @@ function ensureCsrfToken(): string { const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null; - try { - const raw = retrieveRawInitData(); - if (raw) { - tokenStorage.setTelegramInitData(raw); - return raw; - } - } catch {} + const raw = readTelegramInitData(); + if (raw) { + tokenStorage.setTelegramInitData(raw); + return raw; + } return tokenStorage.getTelegramInitData(); }; diff --git a/src/api/landings.ts b/src/api/landings.ts index 8b18f9d..207ecc9 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -114,6 +114,10 @@ export interface PurchaseRequest { yandex_cid?: string; referrer?: string; subid?: string; + // Слаг рекламной кампании: без него покупка гостем не попадает в статистику + // кампании и не даёт её бонус — auth-флоу, который привязывает кампанию + // обычно, на этом пути не срабатывает. + campaign_slug?: string; } export interface PurchaseResponse { diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index e459f1e..e162937 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -17,6 +17,8 @@ export interface PromoCode { balance_bonus_kopeks: number; balance_bonus_rubles: number; subscription_days: number; + /** Гигабайты к подписке — третья составляющая набора бонусов. */ + traffic_gb: number; max_uses: number; current_uses: number; uses_left: number; @@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest { type: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; @@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest { type?: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 15dc748..9cc0c59 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -26,6 +26,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const [oidcError, setOidcError] = useState(''); const [scriptLoaded, setScriptLoaded] = useState(false); const [scriptFailed, setScriptFailed] = useState(false); + // Lets the user opt into deep-link auth manually, without waiting for the + // Telegram widget script to fail. See #. + const [manualDeepLink, setManualDeepLink] = useState(false); + const showDeepLinkUI = scriptFailed || manualDeepLink; const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC); // Deep link auth state @@ -168,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget); useEffect(() => { - if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return; + // showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер + // виджета размонтирован, а при возврате «Назад к виджету» сам по себе + // эффект не перезапустится — на legacy-пути scriptLoaded не меняется + // никогда, поэтому ни одна из остальных зависимостей не дрогнет, и + // пользователь получил бы пустое место вместо виджета. + if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return; const container = containerRef.current; while (container.firstChild) { @@ -229,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto container.removeChild(container.firstChild); } }; - }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]); + }, [ + showDeepLinkUI, + isOIDC, + botUsername, + widgetConfig, + loginWithTelegramWidget, + navigate, + handleScriptFailed, + ]); // Deep link auth: request token and start polling with recursive setTimeout const startDeepLinkAuth = useCallback(async () => { @@ -336,9 +353,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } }, [botUsername, loginWithDeepLink, navigate, t]); - // Auto-start deep link auth when script fails (with cancellation for Strict Mode) + // Auto-start deep link auth when script fails OR the user opts in manually + // (with cancellation for Strict Mode) useEffect(() => { - if (scriptFailed && !deepLinkToken && !deepLinkPolling) { + if (showDeepLinkUI && !deepLinkToken && !deepLinkPolling) { let cancelled = false; const start = async () => { if (!cancelled) await startDeepLinkAuth(); @@ -348,7 +366,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto cancelled = true; }; } - }, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); + }, [showDeepLinkUI, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); // Resume polling immediately when user returns to the page (e.g. after confirming in Telegram) // Browsers throttle setTimeout in background tabs, so polling may have stalled. @@ -431,8 +449,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto ); } - // Deep link fallback UI - if (scriptFailed) { + // Deep link UI — shown either as an automatic fallback (widget script + // failed to load) or because the user explicitly chose this method. + if (showDeepLinkUI) { const resolvedBotUsername = deepLinkBotUsername || botUsername; const deepLinkUrl = deepLinkToken ? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}` @@ -443,7 +462,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
{/* Info message */}

- {t('auth.telegramWidgetBlocked')} + {t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}

{deepLinkToken && deepLinkUrl ? ( @@ -517,6 +536,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto {t('common.loading')}
)} + + {/* Only offer a way back if the widget actually works — if the + script failed there is nothing to go back to. */} + {!scriptFailed && ( + + )} ); } @@ -551,24 +591,41 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
)} -
-

{t('auth.orOpenInApp')}

+ {/* Referral deep link — only relevant for not-yet-registered users who + arrived via a referral link; the bot itself handles registering + them with the code attached. Hidden otherwise to avoid a third, + visually-identical "Telegram" entry point next to the two auth + methods below. */} + {referralCode && ( - - - - @{botUsername} + {t('auth.orOpenInApp')} @{botUsername} + )} + +
+
+ {t('common.or')} +
+ + {/* Manual opt-in: same deep-link flow used as the anti-block fallback, + offered here as an explicit equal alternative to the widget for + users who'd rather confirm in the bot than type a phone number. */} +
); } diff --git a/src/components/admin/remnawave/GeoCheckImageViewer.tsx b/src/components/admin/remnawave/GeoCheckImageViewer.tsx new file mode 100644 index 0000000..4775487 --- /dev/null +++ b/src/components/admin/remnawave/GeoCheckImageViewer.tsx @@ -0,0 +1,167 @@ +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; +import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons'; + +/** + * Ширина в CSS-пикселях, при которой моноширинный отчёт ещё читается. + * Отчёт всегда заметно шире телефона, поэтому «вписать по ширине» на узком + * экране превращает его в серую рябь. Стартуем с масштаба, дающего примерно + * такую ширину, а вписать целиком всегда можно кнопкой сброса и жестом. + */ +const READABLE_WIDTH = 760; + +/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */ +/** Ширины строк-заглушек, повторяющие ритм отчёта: заголовок, строки, пробел. */ +const SKELETON_ROWS = [42, 88, 80, 84, 30, 70, 76, 82, 64, 28, 86, 74, 80, 68]; + +const SCALE_MIN = 1; +const SCALE_MAX = 8; + +interface GeoCheckImageViewerProps { + src: string; + alt: string; + /** Смена режима меняет доступную ширину — просмотрщик пересобирается. */ + fullscreen: boolean; +} + +/** + * Просмотр широкого SVG-отчёта: зум, перетаскивание, пинч, двойной тап. + * + * Зум свой, а не браузерный: в `index.html` приложения стоит + * `user-scalable=no`, поэтому нативного пинча нет ни в мобильном вебе, ни в + * Mini App. Библиотека берёт на себя все жесты и работает одинаково от мыши, + * пальца и трекпада — так же это сделано в самой панели Remnawave. + * + * Колесо намеренно не масштабирует (`wheelDisabled`): иначе обычная прокрутка + * над отчётом превращалась бы в зум. + */ +export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewerProps) { + const { t } = useTranslation(); + const hostRef = useRef(null); + const [width, setWidth] = useState(0); + // Отчёт весит сотни килобайт вместе со встроенным шрифтом, и между приходом + // данных и первой отрисовкой есть заметная пауза. До неё показываем + // скелетон, а не пустую тёмную коробку с кнопками зума. + // + // Готовность хранится как «какая картинка отрисована», а не флагом: тогда + // новый отчёт автоматически считается незагруженным, без сбрасывающего + // эффекта. + const [loadedSrc, setLoadedSrc] = useState(null); + const loaded = loadedSrc === src; + + // Подпись отчёта для ключа. Длины и хвоста base64 недостаточно: отчёты + // одного узла кончаются одинаково и совпали бы по такой подписи, а зум + // тогда не сбрасывается. Полная строка в ключе — сотни килобайт на каждый + // рендер, поэтому считаем хеш один раз на отчёт. + const srcId = useMemo(() => { + let hash = 0; + for (let i = 0; i < src.length; i += 1) { + hash = (hash * 31 + src.charCodeAt(i)) | 0; + } + return `${src.length}:${hash}`; + }, [src]); + + useLayoutEffect(() => { + const host = hostRef.current; + if (!host) return; + + const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); + observer.observe(host); + setWidth(host.clientWidth); + return () => observer.disconnect(); + }, []); + + // Масштаб 1 = вписано по ширине. На широком экране этого достаточно, на + // узком — поднимаем до читаемого и позволяем панорамировать. + const initialScale = width > 0 ? Math.min(SCALE_MAX, Math.max(1, READABLE_WIDTH / width)) : 1; + + const controlButton = + 'flex h-9 w-9 items-center justify-center rounded-lg text-dark-200 transition-colors hover:bg-dark-100/10 active:scale-95'; + + return ( +
+ {!loaded && ( +
+ {SKELETON_ROWS.map((w) => ( +
+ ))} +
+ )} + {width > 0 && ( + + {({ resetTransform, zoomIn, zoomOut }) => ( + <> + + {alt} setLoadedSrc(src)} + onError={() => setLoadedSrc(src)} + /> + + + {loaded && ( +
+ + + + +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/admin/remnawave/GeoCheckModal.tsx b/src/components/admin/remnawave/GeoCheckModal.tsx new file mode 100644 index 0000000..0bd6a32 --- /dev/null +++ b/src/components/admin/remnawave/GeoCheckModal.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { NodeInfo } from '@/api/adminRemnawave'; +import { GeoCheckIcon, PlayIcon, WarningIcon, XCloseIcon } from '@/components/icons'; +import { Spinner } from '@/components/ui/Spinner'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; +import { useIsTelegram } from '@/platform/hooks/usePlatform'; +import { cn } from '@/lib/utils'; +import { GeoCheckReport } from './GeoCheckReport'; +import { buildGeoCheckRequest, isRouteReady, type GeoCheckRouteMode } from './geoCheckRoute'; +import { GeoCheckSetup } from './GeoCheckSetup'; +import { useGeoCheckJob } from './useGeoCheckJob'; + +interface GeoCheckModalProps { + node: NodeInfo; + onClose: () => void; +} + +/** + * GeoCheck ноды: выбор маршрута → ожидание → отчёт. + * + * Полноэкранный просмотр — режим самой модалки, а не отдельный оверлей: + * оверлей поверх портала выпал бы из ловушки фокуса, и его кнопка закрытия + * стала бы недоступна с клавиатуры. + */ +export function GeoCheckModal({ node, onClose }: GeoCheckModalProps) { + const { t } = useTranslation(); + const [mode, setMode] = useState('default'); + const [value, setValue] = useState(''); + const [fullscreenRequested, setFullscreenRequested] = useState(false); + + // В Mini App окно Telegram и так занимает экран целиком: отдельный + // полноэкранный режим там только снимал отступы и залезал под хром. + // Тот же признак закрывает и скачивание: в webview Telegram оно уводит + // из приложения вместо сохранения файла. + const isTelegram = useIsTelegram(); + const canFullscreen = !isTelegram; + const canDownload = !isTelegram; + const fullscreen = canFullscreen && fullscreenRequested; + + const job = useGeoCheckJob(node.uuid); + const isRunning = job.phase === 'running'; + + // Пока идёт проверка, закрывать по Escape нельзя: задача уже поставлена + // в панель, и молча потерять её результат — хуже, чем подождать. + const dialogRef = useFocusTrap(true, { + onEscape: isRunning ? undefined : fullscreen ? () => setFullscreenRequested(false) : onClose, + }); + + useEffect(() => { + if (job.phase !== 'done') setFullscreenRequested(false); + }, [job.phase]); + + const canStart = isRouteReady(mode, value); + + const handleStart = () => { + if (!canStart) return; + job.start(buildGeoCheckRequest(mode, value)); + }; + + const handleBackdropClick = () => { + if (!isRunning) onClose(); + }; + + const errorText = + job.error?.kind === 'timeout' + ? t( + 'admin.remnawave.geoCheck.error.timeout', + 'The node did not answer in time. Try running the check again.', + ) + : (job.error?.message ?? + t('admin.remnawave.geoCheck.error.generic', 'The check could not be completed.')); + + return createPortal( +
+
+ +
+ {/* В полноэкранном режиме шапка ужимается в одну строку: место по + вертикали — ровно то, ради чего его и включают. */} +
+
+ + + +
+

+ {fullscreen ? node.name : t('admin.remnawave.geoCheck.title', 'GeoCheck')} +

+ {!fullscreen &&

{node.name}

} +
+
+ {!isRunning && ( + + )} +
+ + {job.phase === 'idle' && ( + <> + { + setMode(next); + setValue(''); + }} + onValueChange={setValue} + /> +
+ + +
+ + )} + + {isRunning && ( +
+ +

+ {t('admin.remnawave.geoCheck.running', 'Running the geo check')} +

+

+ {t( + 'admin.remnawave.geoCheck.runningHint', + 'The node is testing its connection — this usually takes up to a minute.', + )} +

+
+ )} + + {job.phase === 'error' && ( +
+ + + +

+ {t('admin.remnawave.geoCheck.error.title', 'Check failed')} +

+

{errorText}

+
+ + +
+
+ )} + + {job.phase === 'done' && job.result && ( + setFullscreenRequested((v) => !v)} + onRerun={job.retry} + /> + )} +
+
, + document.body, + ); +} diff --git a/src/components/admin/remnawave/GeoCheckReport.tsx b/src/components/admin/remnawave/GeoCheckReport.tsx new file mode 100644 index 0000000..f3a8f03 --- /dev/null +++ b/src/components/admin/remnawave/GeoCheckReport.tsx @@ -0,0 +1,231 @@ +import { useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { GeoCheckResult } from '@/api/adminRemnawave'; +import { + CheckIcon, + CodeIcon, + CollapseIcon, + CopyIcon, + DownloadIcon, + ExpandIcon, + EyeIcon, + RefreshIcon, +} from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { copyToClipboard } from '@/utils/clipboard'; +import { GeoCheckImageViewer } from './GeoCheckImageViewer'; + +type ReportTab = 'report' | 'json'; + +const TOOLBAR_BUTTON = + 'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40'; + +interface GeoCheckReportProps { + result: GeoCheckResult; + nodeName: string; + fullscreen: boolean; + /** + * Показывать ли переключатель полного экрана. В Telegram Mini App окно и так + * во весь экран — режим лишь снимал отступы и лез под хром Telegram. + */ + canFullscreen: boolean; + /** + * Показывать ли скачивание. В webview Telegram атрибут `download` + * игнорируется: вместо сохранения файла webview уходит на blob и подменяет + * собой Mini App, вернуться можно только кнопкой «Назад». Штатный + * `downloadFile` из Bot API 8.0 тут не помогает — он требует HTTPS-ссылку + * на файл, а отчёт приходит base64 внутри JSON. + */ + canDownload: boolean; + onToggleFullscreen: () => void; + onRerun: () => void; +} + +/** + * Готовый отчёт GeoCheck: картинка либо тот же отчёт в JSON. + * + * Картинка вставляется как ``, а не сырым SVG в разметку: + * отчёт несёт встроенный моноширинный шрифт и `