diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a835e5c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + # Схлопываем шумные обновления в один PR, чтобы не плодить десятки веток. + dev-dependencies: + dependency-type: "development" + production-minor-patch: + dependency-type: "production" + update-types: + - "minor" + - "patch" + labels: + - "dependencies" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "docker" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..62647e2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: CodeQL + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + # Еженедельный полный проход, чтобы ловить новые правила даже без коммитов. + - cron: "27 3 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: javascript-typescript + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:javascript-typescript" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 10df084..6468ebb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,6 +12,9 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} +permissions: + contents: read + jobs: lint: uses: ./.github/workflows/lint.yml @@ -22,6 +25,7 @@ jobs: permissions: contents: read packages: write + security-events: write steps: - name: Checkout code @@ -53,6 +57,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image + id: build uses: docker/build-push-action@v5 with: context: . @@ -66,3 +71,30 @@ jobs: VITE_TELEGRAM_BOT_USERNAME= VITE_APP_NAME=Cabinet VITE_APP_LOGO=V + + # OCI-референсы требуют lowercase, а github.repository сохраняет регистр + # (BEDOLAGA-DEV/…) — Trivy падал бы на 'repository name must be lowercase'. + - name: Lowercase image name for Trivy + if: github.event_name != 'pull_request' + id: image + run: echo "name=${IMAGE_NAME,,}" >> "$GITHUB_OUTPUT" + + # Сканируем запушенный образ на CVE. Пока non-blocking (exit-code: 0): + # находки уезжают в Security → Code scanning, но сборку не роняют. + # Для ужесточения — поставить severity-фильтр в exit-code и убрать PR-гейт. + - name: Scan image with Trivy + if: github.event_name != 'pull_request' + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }} + format: sarif + output: trivy-results.sarif + severity: HIGH,CRITICAL + exit-code: "0" + + - name: Upload Trivy results to Security tab + if: github.event_name != 'pull_request' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: trivy-results.sarif + category: trivy diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..82d0bda --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,35 @@ +name: Security Audit + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "27 4 * * 1" + +permissions: + contents: read + +jobs: + npm-audit: + name: npm audit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + # Пока non-blocking: сообщаем о HIGH/CRITICAL, но не валим пайплайн. + # Когда долги по зависимостям разгребём — убрать continue-on-error. + - name: Run npm audit (high+) + continue-on-error: true + run: npm audit --audit-level=high diff --git a/Dockerfile b/Dockerfile index de296d3..7380a52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,10 @@ ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_LOGO=$VITE_APP_LOGO -# Build the application -RUN npm run build +# Build the application. Type-check намеренно пропущен: tsc --noEmit уже +# гоняется CI на каждый PR (lint.yml), образ собирается из проверенного +# коммита - повторная проверка стоила бы ~10s на каждую сборку. +RUN npm run build:docker # Stage 2: Serve with Nginx FROM nginx:alpine @@ -40,3 +42,4 @@ EXPOSE 80 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 + diff --git a/README.md b/README.md index cc89889..b366a9e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Bedolaga Cabinet - Web Interface -Веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot V3.0.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). +Веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot v3.33.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). React + Vite + TypeScript | Авторизация через Telegram | Мультиязычность (EN/RU) | Адаптивный дизайн ## Требования - Docker и Docker Compose -- Запущенный backend бота с включенным Cabinet API +- Запущенный backend бота с включенным Cabinet API, версия **v3.33.0 или новее** — авторизация кабинета использует deep-link эндпоинты (`/cabinet/auth/deeplink/*`), которых нет в более старых версиях. На старом боте кабинет не сможет авторизовать пользователей (залоченные теги вроде `:3` не подходят — используйте `:latest` или конкретный тег ≥ 3.33.0) - Обратный прокси (Caddy / Nginx / Traefik) ## Архитектура diff --git a/package.json b/package.json index 589d63f..8179254 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:docker": "vite build", "lint": "biome lint .", "lint:fix": "biome lint --write .", "format": "biome format --write .", diff --git a/public/miniapp/redirect.html b/public/miniapp/redirect.html index cca3db1..3382186 100644 --- a/public/miniapp/redirect.html +++ b/public/miniapp/redirect.html @@ -129,41 +129,56 @@ // `file:` URI (DOM-XSS) is not scheme://-shaped, and http(s)/intent // are blocked (open redirect / intent abuse) — so only VPN app // schemes (happ://, vless://, ...) pass. Mirrors the validation in - // src/pages/DeepLinkRedirect.tsx. - function isSafeAppLink(raw) { - var s = String(raw || '').trim().toLowerCase(); - var m = s.match(/^([a-z][a-z0-9+.\-]*):\/\//); - if (!m) return false; - var blocked = ['http', 'https', 'file', 'blob', 'about', 'javascript', 'data', 'vbscript', 'intent', 'content']; - return blocked.indexOf(m[1]) === -1; - } - - if (!url || !isSafeAppLink(url)) { + // src/pages/DeepLinkRedirect.tsx. Both checks are INLINE + // prefix-anchored RegExp tests so CodeQL recognizes them as taint + // barriers (js/xss, js/client-side-unvalidated-url-redirection). + function showNoUrl() { document.getElementById('title').textContent = t.noUrl; document.getElementById('subtitle').textContent = ''; document.querySelector('.spinner').style.display = 'none'; + } + + if (!url || !/^[a-z][a-z0-9+.\-]*:\/\//i.test(url)) { + showNoUrl(); + return; + } + if (/^(https?|file|blob|about|javascript|data|vbscript|intent|content|filesystem):/i.test(url)) { + showNoUrl(); return; } const manualBtn = document.getElementById('manualBtn'); manualBtn.href = url; - // Attempt to launch the app WITHOUT a top-level navigation. A direct - // `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME - // inside Android in-app browsers (Telegram/Yandex/…) and silently fails on - // iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is - // contained: the app opens if installed, otherwise the failure stays inside - // the (invisible) frame and the button below stays usable. - try { - var frame = document.createElement('iframe'); - frame.style.display = 'none'; - frame.src = url; - document.body.appendChild(frame); - setTimeout(function() { - try { frame.parentNode.removeChild(frame); } catch (e) {} - }, 2000); - } catch (e) { + // iPadOS 13+ reports itself as desktop Safari, so a touch-capable "Mac" + // is treated as an iPad. + function isIOS() { + var ua = navigator.userAgent || ''; + if (/iP(ad|hone|od)/.test(ua)) return true; + return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; + } + + // Attempt to auto-launch the app. Two opposite fixes: + // - iOS launches a custom scheme only from a top-level navigation; a hidden + // iframe is a silent no-op there, so use location.href. + // - Android in-app browsers (Telegram/Yandex/…) paint a full-page + // net::ERR_UNKNOWN_URL_SCHEME on a top-level nav to an unresolved scheme + // (Telegram bug #654272), so a contained hidden iframe is used instead. + // Either way the manual "Open app" button below stays as the reliable path. + if (isIOS()) { window.location.href = url; + } else { + try { + var frame = document.createElement('iframe'); + frame.style.display = 'none'; + frame.src = url; + document.body.appendChild(frame); + setTimeout(function() { + try { frame.parentNode.removeChild(frame); } catch (e) {} + }, 2000); + } catch (e) { + window.location.href = url; + } } // Programmatic opening is unreliable in in-app browsers, so surface the diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 1bc20f2..ef47cda 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -21,6 +21,8 @@ export interface UserSubscriptionInfo { tariff_id: number | null; tariff_name: string | null; autopay_enabled: boolean; + sbp_recurring_status: string | null; + sbp_recurring_id: number | null; is_active: boolean; days_remaining: number; purchased_traffic_gb: number; @@ -508,6 +510,14 @@ export const adminUsersApi = { return response.data; }, + // Cancel a user's SBP (Platega) recurring auto-payment + cancelSbpRecurring: async (userId: number, subId: number): Promise<{ status: string }> => { + const response = await apiClient.post( + `/cabinet/admin/users/${userId}/subscriptions/${subId}/cancel-sbp-recurring`, + ); + return response.data; + }, + // Update status updateStatus: async ( userId: number, diff --git a/src/api/branding.ts b/src/api/branding.ts index e57a130..72d6ffc 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -1,4 +1,5 @@ import apiClient from './client'; +import { isEndpointMissingError } from '../utils/api-error'; import type { AnimationConfig } from '@/components/ui/backgrounds/types'; import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types'; @@ -36,6 +37,8 @@ export interface TelegramWidgetConfig { request_access: boolean; oidc_enabled: boolean; oidc_client_id: string; + /** The bot answered 404 on the config route — build older than v3.24.0. */ + endpoint_missing?: boolean; } export interface OfflineConvGoal { @@ -321,7 +324,7 @@ export const brandingApi = { '/cabinet/branding/telegram-widget', ); return response.data; - } catch { + } catch (err) { return { bot_username: import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '', size: 'large', @@ -330,6 +333,7 @@ export const brandingApi = { request_access: true, oidc_enabled: false, oidc_client_id: '', + endpoint_missing: isEndpointMissingError(err), }; } }, diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 86f744a..4572a0e 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -12,6 +12,7 @@ import type { PurchaseSelection, PurchasePreview, AppConfig, + SbpRecurringInfo, } from '../types'; /** Helper: build query params with optional subscription_id */ @@ -349,6 +350,50 @@ export const subscriptionApi = { return response.data; }, + // ── SBP recurring (Platega) ─────────────────────────────────────────── + + getSbpRecurring: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/cabinet/subscription/platega-recurrent', + withSubId(subscriptionId), + ); + return response.data; + }, + + enableSbpRecurring: async ( + subscriptionId?: number, + ): Promise<{ status: string; redirect_url: string | null }> => { + const response = await apiClient.post( + '/cabinet/subscription/platega-recurrent/enable', + ...bodyWithSubId({}, subscriptionId), + ); + return response.data; + }, + + cancelSbpRecurring: async (subscriptionId?: number): Promise<{ status: string }> => { + const response = await apiClient.post( + '/cabinet/subscription/platega-recurrent/cancel', + ...bodyWithSubId({}, subscriptionId), + ); + return response.data; + }, + + /** + * Оформление подписки на тариф через СБП-автопродление: первое списание = + * подтверждение привязки в банке. Для нового тарифа бэкенд создаёт + * неактивную заготовку, которую активирует первый чардж. + */ + purchaseWithSbpRecurring: async ( + tariffId: number, + ): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => { + const response = await apiClient.post( + '/cabinet/subscription/platega-recurrent/purchase', + {}, + { params: { tariff_id: tariffId } }, + ); + return response.data; + }, + // ── Trial ─────────────────────────────────────────────────────────── getTrialInfo: async (): Promise => { diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index c31df2c..15dc748 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -9,6 +9,7 @@ import { useAuthStore } from '../store/auth'; import { useNavigate } from 'react-router'; import { getPendingCampaignSlug } from '../utils/campaign'; import { copyToClipboard } from '../utils/clipboard'; +import { isEndpointMissingError } from '../utils/api-error'; interface TelegramLoginButtonProps { referralCode?: string; @@ -159,7 +160,6 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access, - t, scriptLoaded, handleScriptFailed, ]); @@ -330,8 +330,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto }, (expires_in || 300) * 1000, ); - } catch { - setDeepLinkError(t('common.error')); + } catch (err) { + // 404 = the deep-link auth routes don't exist on this bot build (< v3.33.0) + setDeepLinkError(t(isEndpointMissingError(err) ? 'auth.botOutdated' : 'common.error')); } }, [botUsername, loginWithDeepLink, navigate, t]); @@ -421,9 +422,11 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto }, [deepLinkPolling, deepLinkToken, loginWithDeepLink, navigate, t]); if (!botUsername || botUsername === 'your_bot') { + // 404 on the config route = the bot build predates the cabinet endpoints, + // which reads as "not configured" but is actually a version mismatch (#345). return (
- {t('auth.telegramNotConfigured')} + {t(widgetConfig?.endpoint_missing ? 'auth.botOutdated' : 'auth.telegramNotConfigured')}
); } diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx index f8b38ea..31e0ab1 100644 --- a/src/components/WebSocketNotifications.tsx +++ b/src/components/WebSocketNotifications.tsx @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; -import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; +import { useWebSocket, type WSMessage } from '../hooks/useWebSocket'; import { useToast } from './Toast'; import { useAuthStore } from '../store/auth'; import { useCurrency } from '../hooks/useCurrency'; @@ -296,6 +296,131 @@ export default function WebSocketNotifications() { return; } + // SBP recurring events + if (type === 'sbp_recurring.confirmed') { + const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; + showToast({ + type: 'success', + title: t('wsNotifications.sbpRecurring.confirmedTitle', 'SBP payment confirmed'), + message: t( + 'wsNotifications.sbpRecurring.confirmedMessage', + 'Payment of {{amount}} {{currency}} was charged successfully', + { + amount: formatAmount(amount), + currency: currencySymbol, + }, + ), + icon: 🔁, + onClick: () => navigate('/subscriptions'), + duration: 8000, + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); + refreshUser(); + return; + } + + if (type === 'sbp_recurring.activated') { + showToast({ + type: 'success', + title: t('wsNotifications.sbpRecurring.activatedTitle', 'SBP auto-payment connected'), + message: t( + 'wsNotifications.sbpRecurring.activatedMessage', + 'SBP auto-payment has been successfully connected', + ), + icon: 🔁, + onClick: () => navigate('/subscriptions'), + duration: 8000, + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); + refreshUser(); + return; + } + + if (type === 'sbp_recurring.failed') { + showToast({ + type: 'error', + title: t('wsNotifications.sbpRecurring.failedTitle', 'SBP payment failed'), + message: t( + 'wsNotifications.sbpRecurring.failedMessage', + 'Failed to charge your SBP auto-payment', + ), + icon: , + onClick: () => navigate('/subscriptions'), + duration: 10000, + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring', + }); + return; + } + + if (type === 'sbp_recurring.past_due') { + showToast({ + type: 'warning', + title: t('wsNotifications.sbpRecurring.pastDueTitle', 'SBP payment overdue'), + message: t( + 'wsNotifications.sbpRecurring.pastDueMessage', + 'Your SBP auto-payment is overdue. Please check your banking app.', + ), + icon: ⚠️, + onClick: () => navigate('/subscriptions'), + duration: 10000, + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring', + }); + return; + } + + if (type === 'sbp_recurring.cancelled') { + showToast({ + type: 'warning', + title: t('wsNotifications.sbpRecurring.cancelledTitle', 'SBP auto-payment disabled'), + message: t( + 'wsNotifications.sbpRecurring.cancelledMessage', + 'SBP auto-payment has been disabled', + ), + icon: 🔕, + onClick: () => navigate('/subscriptions'), + duration: 8000, + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring', + }); + return; + } + // Account events if (type === 'account.banned') { showToast({ diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index d633dc8..a8b83c8 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -130,6 +130,7 @@ export interface SubscriptionTabProps { onAddTraffic: (gb: number) => Promise; onRemoveTraffic: (purchaseId: number) => Promise; onResetDevices: () => Promise; + onCancelSbpRecurring: () => Promise; onDeleteDevice: (hwid: string) => Promise; onRenameDevice: (hwid: string) => Promise; onLoadDevices: () => Promise; @@ -193,6 +194,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { onAddTraffic, onRemoveTraffic, onResetDevices, + onCancelSbpRecurring, onDeleteDevice, onRenameDevice, onLoadDevices, @@ -226,6 +228,14 @@ export function SubscriptionTab(props: SubscriptionTabProps) { {sub.tariff_name || `#${sub.id}`} + {sub.sbp_recurring_status && ( + + SBP + + )} @@ -380,6 +390,45 @@ export function SubscriptionTab(props: SubscriptionTabProps) { + {/* SBP (Platega) recurring auto-payment — status comes from the admin + detail response; cancel is idempotent on the backend. */} + {selectedSub.sbp_recurring_status && ( +
+
+
+
+ {t('admin.users.detail.subscription.sbpTitle')} +
+
+ {t( + `admin.users.detail.subscription.sbpStatus_${selectedSub.sbp_recurring_status}`, + selectedSub.sbp_recurring_status, + )} +
+
+ {hasPermission('users:subscription') && ( + + )} +
+
+ )} + {/* Traffic Packages */} {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index b49e13b..cc76ed5 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -4,6 +4,7 @@ import { useBlockingStore } from '../../store/blocking'; import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { usePlatform } from '../../platform'; import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { customChannelMessage } from '../../utils/channelBlockingMessage'; import { TelegramIcon, ClockIcon, CheckIcon, RestartIcon } from '@/components/icons'; import { Button } from '@/components/primitives'; import BlockingShell from './BlockingShell'; @@ -108,7 +109,9 @@ export default function ChannelSubscriptionScreen() { ariaLive="polite" icon={} title={t('blocking.channel.title')} - description={channelInfo?.message || t('blocking.channel.defaultMessage')} + description={ + customChannelMessage(channelInfo?.message) ?? t('blocking.channel.defaultMessage') + } footer={t('blocking.channel.hint')} actions={ +
+ {t('subscription.sbpRecurring.purchaseHint')} +
+ {sbpPurchaseMutation.isError && ( +
+ {getErrorMessage(sbpPurchaseMutation.error)} +
+ )} + + ); + // Smooth scroll the form into view when first mounted. useEffect(() => { if (ref.current) { @@ -190,6 +239,8 @@ export function TariffPurchaseForm({ )} + {sbpPurchaseButton} + {purchaseMutation.isError && !getInsufficientBalanceError(purchaseMutation.error) && (
@@ -613,6 +664,8 @@ export function TariffPurchaseForm({ t('subscription.purchase') )} + + {sbpPurchaseButton} ); })()} diff --git a/src/locales/en.json b/src/locales/en.json index 774cbb4..6824c06 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -116,6 +116,18 @@ "insufficientTitle": "Insufficient funds", "insufficientMessage": "Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}" }, + "sbpRecurring": { + "confirmedTitle": "SBP payment confirmed", + "confirmedMessage": "Payment of {{amount}} {{currency}} was charged successfully", + "failedTitle": "SBP payment failed", + "failedMessage": "Failed to charge your SBP auto-payment", + "pastDueTitle": "SBP payment overdue", + "pastDueMessage": "Your SBP auto-payment is overdue. Please check your banking app.", + "cancelledTitle": "SBP auto-payment disabled", + "cancelledMessage": "SBP auto-payment has been disabled", + "activatedTitle": "SBP auto-payment connected", + "activatedMessage": "SBP auto-payment has been successfully connected" + }, "account": { "bannedTitle": "Account blocked", "bannedMessage": "Your account has been blocked", @@ -189,6 +201,7 @@ "telegramRetryFailed": "Authorization failed. Close the app and try again.", "telegramReopenHint": "If the problem persists, close and reopen the app", "telegramNotConfigured": "Telegram bot is not configured", + "botOutdated": "The bot version is not compatible with the cabinet. Update the bot to v3.33.0 or newer.", "telegramUnavailable": "Telegram login is temporarily unavailable", "authenticating": "Authenticating...", "orOpenInApp": "Or open the bot in the app", @@ -250,6 +263,7 @@ "dashboard": { "title": "Dashboard", "welcome": "Welcome, {{name}}!", + "welcomeNoName": "Welcome!", "yourSubscription": "Your Subscription", "quickActions": "Quick Actions", "viewSubscription": "Manage Subscription", @@ -663,6 +677,30 @@ }, "allTariffsPurchased": "All tariffs already purchased", "autopay": "Auto-renew", + "sbpRecurring": { + "title": "SBP Auto-payment", + "connect": "Connect", + "confirmInBank": "Confirm in your banking app", + "cancel": "Disable", + "confirmCancel": "Disable SBP auto-payment? Your subscription will no longer renew automatically via SBP.", + "cancelled": "SBP auto-payment disabled", + "enableError": "Failed to connect SBP auto-payment", + "cancelError": "Failed to cancel SBP auto-payment", + "statusPending": "Waiting for confirmation in your banking app", + "statusActive": "Active", + "statusPastDue": "Payment failed — please retry", + "nextCharge": "next charge {{date}}", + "amountPerInterval": "{{amount}} ₽ {{interval}}", + "interval": { + "day": "daily", + "week": "weekly", + "month": "monthly", + "year": "yearly" + }, + "autopayHint": "Your subscription will be charged automatically via SBP, no card required", + "purchaseButton": "⚡ Subscribe with SBP auto-payment", + "purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence." + }, "backToList": "Back to subscriptions", "confirmDelete": "Delete subscription?", "dailyAutoCharge": "Daily auto-charge", @@ -839,7 +877,10 @@ "pageTitle": "Saved Cards", "backToBalance": "Back", "empty": "No saved cards. A card will be saved automatically on your next top-up.", - "loadError": "Failed to load saved cards. Please try again later." + "loadError": "Failed to load saved cards. Please try again later.", + "sbpSection": "SBP Auto-payment", + "sbpBinding": "SBP binding", + "sbpUnlink": "Disable SBP auto-payment" }, "paymentReady": "Payment link is ready", "clickToOpenPayment": "Click the button below to open the payment page in a new tab", @@ -3539,7 +3580,13 @@ "deviceLimitUpdated": "Device limit updated", "invalidDays": "Please enter a valid number of days (greater than 0)", "backToList": "Back to subscriptions", - "createNew": "Create new" + "createNew": "Create new", + "sbpTitle": "SBP Auto-payment", + "sbpCancel": "Disable", + "sbpCancelled": "SBP auto-payment disabled", + "sbpStatus_PENDING": "Pending", + "sbpStatus_ACTIVE": "Active", + "sbpStatus_PAST_DUE": "Payment failed" }, "balance": { "current": "Current balance", diff --git a/src/locales/fa.json b/src/locales/fa.json index 518fe03..224e3ec 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -110,6 +110,18 @@ "insufficientTitle": "موجودی ناکافی", "insufficientMessage": "برای تمدید {{required}} {{currency}} لازم است، موجودی فعلی {{balance}} {{currency}}" }, + "sbpRecurring": { + "confirmedTitle": "پرداخت SBP تأیید شد", + "confirmedMessage": "مبلغ {{amount}} {{currency}} با موفقیت برداشت شد", + "failedTitle": "پرداخت SBP ناموفق بود", + "failedMessage": "برداشت پرداخت خودکار SBP ناموفق بود", + "pastDueTitle": "پرداخت SBP معوق است", + "pastDueMessage": "پرداخت خودکار SBP شما معوق است. لطفاً اپلیکیشن بانک را بررسی کنید.", + "cancelledTitle": "پرداخت خودکار SBP غیرفعال شد", + "cancelledMessage": "پرداخت خودکار SBP غیرفعال شده است", + "activatedTitle": "پرداخت خودکار SBP متصل شد", + "activatedMessage": "پرداخت خودکار SBP با موفقیت متصل شد" + }, "account": { "bannedTitle": "حساب مسدود شد", "bannedMessage": "حساب شما مسدود شده است", @@ -181,6 +193,7 @@ "telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.", "telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید", "telegramNotConfigured": "ربات تلگرام پیکربندی نشده", + "botOutdated": "نسخهٔ ربات با کابینت سازگار نیست. ربات را به v3.33.0 یا جدیدتر به‌روزرسانی کنید.", "telegramUnavailable": "ورود از طریق تلگرام موقتاً در دسترس نیست", "authenticating": "در حال تایید هویت...", "orOpenInApp": "یا ربات را در برنامه باز کنید", @@ -235,6 +248,7 @@ "dashboard": { "title": "داشبورد", "welcome": "خوش آمدید، {{name}}!", + "welcomeNoName": "خوش آمدید!", "yourSubscription": "اشتراک شما", "quickActions": "دسترسی سریع", "viewSubscription": "مدیریت اشتراک", @@ -562,6 +576,30 @@ }, "allTariffsPurchased": "همه تعرفه‌ها قبلاً خریداری شده‌اند", "autopay": "تمدید خودکار", + "sbpRecurring": { + "title": "پرداخت خودکار SBP", + "connect": "اتصال", + "confirmInBank": "در اپلیکیشن بانک تأیید کنید", + "cancel": "غیرفعال کردن", + "confirmCancel": "پرداخت خودکار SBP غیرفعال شود؟ اشتراک شما دیگر به‌صورت خودکار از طریق SBP تمدید نخواهد شد.", + "cancelled": "پرداخت خودکار SBP غیرفعال شد", + "enableError": "اتصال پرداخت خودکار SBP ناموفق بود", + "cancelError": "لغو پرداخت خودکار SBP ناموفق بود", + "statusPending": "در انتظار تأیید در اپلیکیشن بانک", + "statusActive": "فعال", + "statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید", + "nextCharge": "برداشت بعدی {{date}}", + "amountPerInterval": "{{amount}} ₽ {{interval}}", + "interval": { + "day": "روزانه", + "week": "هفتگی", + "month": "ماهانه", + "year": "سالانه" + }, + "autopayHint": "اشتراک شما به‌صورت خودکار از طریق SBP بدون نیاز به کارت ذخیره‌شده شارژ می‌شود", + "purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP", + "purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند." + }, "backToList": "بازگشت به فهرست اشتراک‌ها", "confirmDelete": "اشتراک حذف شود؟", "cta": { @@ -727,7 +765,10 @@ "pageTitle": "کارت‌های ذخیره شده", "backToBalance": "بازگشت", "empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.", - "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید." + "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید.", + "sbpSection": "پرداخت خودکار SBP", + "sbpBinding": "اتصال SBP", + "sbpUnlink": "غیرفعال کردن پرداخت خودکار SBP" }, "paymentSuccess": { "title": "پرداخت موفق", @@ -3010,7 +3051,13 @@ "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد", "invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)", "backToList": "بازگشت به فهرست اشتراک‌ها", - "createNew": "ایجاد جدید" + "createNew": "ایجاد جدید", + "sbpTitle": "پرداخت خودکار SBP", + "sbpCancel": "غیرفعال کردن", + "sbpCancelled": "پرداخت خودکار SBP غیرفعال شد", + "sbpStatus_PENDING": "در انتظار", + "sbpStatus_ACTIVE": "فعال", + "sbpStatus_PAST_DUE": "پرداخت ناموفق" }, "balance": { "current": "موجودی فعلی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5424442..1cfef4f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -119,6 +119,18 @@ "insufficientTitle": "Недостаточно средств", "insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}" }, + "sbpRecurring": { + "confirmedTitle": "Платёж по СБП подтверждён", + "confirmedMessage": "Списано {{amount}} {{currency}}", + "failedTitle": "Ошибка платежа по СБП", + "failedMessage": "Не удалось списать платёж по СБП", + "pastDueTitle": "Просрочен платёж по СБП", + "pastDueMessage": "Платёж по СБП просрочен. Проверьте приложение банка.", + "cancelledTitle": "Автоплатёж по СБП отключён", + "cancelledMessage": "Автоплатёж по СБП был отключён", + "activatedTitle": "Автоплатёж по СБП подключён", + "activatedMessage": "Автоплатёж по СБП успешно подключён" + }, "account": { "bannedTitle": "Аккаунт заблокирован", "bannedMessage": "Ваш аккаунт был заблокирован", @@ -192,6 +204,7 @@ "telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.", "telegramReopenHint": "Если проблема повторяется, закройте и откройте приложение заново", "telegramNotConfigured": "Telegram бот не настроен", + "botOutdated": "Версия бота несовместима с кабинетом. Обновите бота до v3.33.0 или новее.", "telegramUnavailable": "Вход через Telegram временно недоступен", "authenticating": "Авторизация...", "orOpenInApp": "Или откройте бота в приложении", @@ -253,6 +266,7 @@ "dashboard": { "title": "Личный кабинет", "welcome": "Добро пожаловать, {{name}}!", + "welcomeNoName": "Добро пожаловать!", "yourSubscription": "Ваша подписка", "quickActions": "Быстрые действия", "viewSubscription": "Управление подпиской", @@ -682,6 +696,30 @@ }, "allTariffsPurchased": "Все тарифы уже куплены", "autopay": "Автопродление", + "sbpRecurring": { + "title": "Автоплатёж по СБП", + "connect": "Подключить", + "confirmInBank": "Подтвердите в приложении банка", + "cancel": "Отключить", + "confirmCancel": "Отключить автоплатёж по СБП? Подписка больше не будет продлеваться автоматически через СБП.", + "cancelled": "Автоплатёж по СБП отключён", + "enableError": "Не удалось подключить автоплатёж по СБП", + "cancelError": "Не удалось отменить автоплатёж по СБП", + "statusPending": "Ожидает подтверждения в приложении банка", + "statusActive": "Активен", + "statusPastDue": "Платёж не прошёл — попробуйте снова", + "nextCharge": "следующее списание {{date}}", + "amountPerInterval": "{{amount}} ₽ {{interval}}", + "interval": { + "day": "ежедневно", + "week": "еженедельно", + "month": "ежемесячно", + "year": "ежегодно" + }, + "autopayHint": "Подписка будет автоматически продлеваться через СБП без сохранённой карты", + "purchaseButton": "⚡ Оформить с автооплатой СБП", + "purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа." + }, "backToList": "К списку подписок", "confirmDelete": "Удалить подписку?", "dailyAutoCharge": "Ежедневное списание", @@ -858,7 +896,10 @@ "pageTitle": "Сохранённые карты", "backToBalance": "Назад", "empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.", - "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже." + "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже.", + "sbpSection": "Автоплатёж по СБП", + "sbpBinding": "Привязка СБП", + "sbpUnlink": "Отключить автоплатёж по СБП" }, "paymentReady": "Ссылка на оплату готова", "clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке", @@ -3936,7 +3977,13 @@ "deviceLimitUpdated": "Лимит устройств обновлён", "invalidDays": "Введите корректное количество дней (больше 0)", "backToList": "К списку подписок", - "createNew": "Создать новую" + "createNew": "Создать новую", + "sbpTitle": "Автоплатёж по СБП", + "sbpCancel": "Отключить", + "sbpCancelled": "Автоплатёж по СБП отключён", + "sbpStatus_PENDING": "Ожидает подтверждения", + "sbpStatus_ACTIVE": "Активен", + "sbpStatus_PAST_DUE": "Платёж не прошёл" }, "balance": { "current": "Текущий баланс", diff --git a/src/locales/zh.json b/src/locales/zh.json index d358bb2..7ebb9af 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -110,6 +110,18 @@ "insufficientTitle": "余额不足", "insufficientMessage": "续期需要 {{required}} {{currency}},当前余额 {{balance}} {{currency}}" }, + "sbpRecurring": { + "confirmedTitle": "SBP 扣款已确认", + "confirmedMessage": "已成功扣款 {{amount}} {{currency}}", + "failedTitle": "SBP 扣款失败", + "failedMessage": "SBP 自动扣款失败", + "pastDueTitle": "SBP 扣款已逾期", + "pastDueMessage": "您的 SBP 自动扣款已逾期,请检查银行应用。", + "cancelledTitle": "SBP 自动扣款已关闭", + "cancelledMessage": "SBP 自动扣款已被关闭", + "activatedTitle": "SBP 自动扣款已开通", + "activatedMessage": "SBP 自动扣款已成功开通" + }, "account": { "bannedTitle": "账户已封禁", "bannedMessage": "您的账户已被封禁", @@ -181,6 +193,7 @@ "telegramRetryFailed": "授权失败。请关闭应用后重新打开。", "telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用", "telegramNotConfigured": "Telegram机器人未配置", + "botOutdated": "机器人版本与后台不兼容,请将机器人更新至 v3.33.0 或更高版本。", "telegramUnavailable": "Telegram登录暂时不可用", "authenticating": "正在验证...", "orOpenInApp": "或在应用中打开机器人", @@ -235,6 +248,7 @@ "dashboard": { "title": "个人中心", "welcome": "欢迎,{{name}}!", + "welcomeNoName": "欢迎!", "yourSubscription": "您的订阅", "quickActions": "快捷操作", "viewSubscription": "管理订阅", @@ -562,6 +576,30 @@ }, "allTariffsPurchased": "所有套餐已购买", "autopay": "自动续订", + "sbpRecurring": { + "title": "SBP 自动扣款", + "connect": "开通", + "confirmInBank": "请在银行应用中确认", + "cancel": "关闭", + "confirmCancel": "关闭 SBP 自动扣款?您的订阅将不再通过 SBP 自动续订。", + "cancelled": "SBP 自动扣款已关闭", + "enableError": "开通 SBP 自动扣款失败", + "cancelError": "取消 SBP 自动扣款失败", + "statusPending": "等待银行应用确认", + "statusActive": "已启用", + "statusPastDue": "扣款失败 — 请重试", + "nextCharge": "下次扣款 {{date}}", + "amountPerInterval": "{{amount}} ₽ {{interval}}", + "interval": { + "day": "每天", + "week": "每周", + "month": "每月", + "year": "每年" + }, + "autopayHint": "您的订阅将通过 SBP 自动扣款,无需保存银行卡", + "purchaseButton": "⚡ 通过SBP自动扣款订阅", + "purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。" + }, "backToList": "返回订阅列表", "confirmDelete": "删除订阅?", "cta": { @@ -727,7 +765,10 @@ "pageTitle": "已保存的银行卡", "backToBalance": "返回", "empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。", - "loadError": "加载银行卡失败,请稍后重试。" + "loadError": "加载银行卡失败,请稍后重试。", + "sbpSection": "SBP 自动扣款", + "sbpBinding": "SBP 绑定", + "sbpUnlink": "关闭 SBP 自动扣款" }, "paymentSuccess": { "title": "支付成功", @@ -3009,7 +3050,13 @@ "deviceLimitUpdated": "设备限制已更新", "invalidDays": "请输入有效天数(大于0)", "backToList": "返回订阅列表", - "createNew": "新建" + "createNew": "新建", + "sbpTitle": "SBP 自动扣款", + "sbpCancel": "关闭", + "sbpCancelled": "SBP 自动扣款已关闭", + "sbpStatus_PENDING": "待确认", + "sbpStatus_ACTIVE": "已启用", + "sbpStatus_PAST_DUE": "扣款失败" }, "balance": { "current": "当前余额", diff --git a/src/pages/AdminTariffCreate.tsx b/src/pages/AdminTariffCreate.tsx index 8a5e095..f7203d2 100644 --- a/src/pages/AdminTariffCreate.tsx +++ b/src/pages/AdminTariffCreate.tsx @@ -4,12 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { tariffsApi, - TariffDetail, - TariffCreateRequest, - TariffUpdateRequest, - PeriodPrice, - ServerInfo, - ExternalSquadInfo, + type TariffDetail, + type TariffCreateRequest, + type TariffUpdateRequest, + type PeriodPrice, + type ServerInfo, + type ExternalSquadInfo, } from '../api/tariffs'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; @@ -153,9 +153,12 @@ export default function AdminTariffCreate() { const handleSubmit = () => { const isDaily = tariffType === 'daily'; + // PATCH applies a field only when it is present in the payload, so empty + // values ('' / []) must still be sent when editing — omitting them makes + // clearing the description or unchecking all promo groups impossible. const data: TariffCreateRequest | TariffUpdateRequest = { name, - description: description || undefined, + description: isEdit ? description : description || undefined, is_active: isActive, show_in_gift: showInGift, traffic_limit_gb: toNumber(trafficLimitGb, 100), @@ -167,7 +170,7 @@ export default function AdminTariffCreate() { period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0), allowed_squads: selectedSquads, external_squad_uuid: selectedExternalSquad || null, - promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined, + promo_group_ids: selectedPromoGroups, traffic_topup_enabled: trafficTopupEnabled, traffic_topup_packages: trafficTopupPackages, max_topup_traffic_gb: toNumber(maxTopupTrafficGb), @@ -600,7 +603,7 @@ export default function AdminTariffCreate() { setEditingPeriodPrices((prev) => ({ ...prev, [period.days]: val })); if (val !== '') { const num = parseFloat(val); - if (!isNaN(num)) { + if (!Number.isNaN(num)) { updatePeriodPrice(period.days, Math.max(0, num)); } } @@ -938,7 +941,7 @@ export default function AdminTariffCreate() { setEditingPackagePrices((prev) => ({ ...prev, [gb]: val })); if (val !== '') { const num = parseFloat(val); - if (!isNaN(num)) { + if (!Number.isNaN(num)) { setTrafficTopupPackages((prev) => ({ ...prev, [gb]: Math.max(0, num) * 100, diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index ba63740..db7abb9 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -648,6 +648,20 @@ export default function AdminUserDetail() { } }; + const handleCancelSbpRecurring = async () => { + if (!userId || !selectedSub?.sbp_recurring_id) return; + setActionLoading(true); + try { + await adminUsersApi.cancelSbpRecurring(userId, selectedSub.id); + notify.success(t('admin.users.detail.subscription.sbpCancelled'), t('common.success')); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const handleDisableUser = async () => { if (!userId) return; setActionLoading(true); @@ -713,7 +727,7 @@ export default function AdminUserDetail() { const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; }; const copyToClipboard = async (text: string) => { @@ -859,6 +873,7 @@ export default function AdminUserDetail() { {/* Header */}

- {t('dashboard.welcome', { name: displayName(user) })} + {userName ? t('dashboard.welcome', { name: userName }) : t('dashboard.welcomeNoName')}

{t('dashboard.yourSubscription')}

@@ -328,40 +330,37 @@ export default function Dashboard() { )} {/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */} - {!isMultiTariff && ( - <> - {subLoading ? ( -
-
-
-
-
-
-
-
-
-
-
+ {!isMultiTariff && + (subLoading ? ( +
+
+
+
- ) : subscription?.is_expired || - subscription?.status === 'disabled' || - subscription?.is_limited ? ( - - ) : subscription ? ( - - ) : null} - - )} +
+
+
+
+
+
+
+ ) : subscription?.is_expired || + subscription?.status === 'disabled' || + subscription?.is_limited ? ( + + ) : subscription ? ( + + ) : null)} {/* Нет подписок: показываем триал (если доступен) и ВСЕГДА одну явную кнопку покупки. Триал не обязателен, чтобы попасть в витрину — раньше diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index 1a3657c..dc3d7ec 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -101,17 +101,21 @@ export default function Referral() { const isPartner = partnerStatus?.partner_status === 'approved'; - // Withdrawal queries (only when partner is approved) + // Withdrawal is available to any referrer: the backend endpoints + // (/cabinet/referral/withdrawal/*) do not require partner_status, so the + // section is gated only by the admin visibility flag. + const withdrawalVisible = terms?.partner_section_visible !== false; + const { data: withdrawalBalance } = useQuery({ queryKey: ['withdrawal-balance'], queryFn: withdrawalApi.getBalance, - enabled: isPartner, + enabled: withdrawalVisible, }); const { data: withdrawalHistory } = useQuery({ queryKey: ['withdrawal-history'], queryFn: withdrawalApi.getHistory, - enabled: isPartner, + enabled: withdrawalVisible, }); // Withdrawal cancel mutation @@ -540,9 +544,9 @@ export default function Referral() {
)} - {/* ==================== Withdrawal Section (approved partners only) ==================== */} + {/* ==================== Withdrawal Section ==================== */} - {terms?.partner_section_visible !== false && isPartner && ( + {withdrawalVisible && (
{/* Withdrawal Balance Card */} {withdrawalBalance && ( diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx index 6501df0..81fec61 100644 --- a/src/pages/SavedCards.tsx +++ b/src/pages/SavedCards.tsx @@ -1,13 +1,15 @@ import { uiLocale } from '@/utils/uiLocale'; import { useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { motion } from 'framer-motion'; import { balanceApi } from '../api/balance'; +import { subscriptionApi } from '../api/subscription'; import { useToast } from '../components/Toast'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; +import type { SbpRecurringInfo, SubscriptionListItem } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; @@ -24,6 +26,25 @@ function formatCardDate(dateStr: string): string { } } +/** Human-readable locale key for an SBP binding status (mirrors Subscription.tsx). */ +function sbpStatusLabelKey(status: string): string | null { + switch (status) { + case 'PENDING': + return 'subscription.sbpRecurring.statusPending'; + case 'ACTIVE': + return 'subscription.sbpRecurring.statusActive'; + case 'PAST_DUE': + return 'subscription.sbpRecurring.statusPastDue'; + default: + return null; + } +} + +interface SbpBinding { + sub: SubscriptionListItem; + info: SbpRecurringInfo; +} + export default function SavedCards() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -73,6 +94,67 @@ export default function SavedCards() { } }; + // ── SBP (Platega) recurring bindings ──────────────────────────────── + // Same query-key convention as the Subscription page (['sbp-recurring', subId]) + // so the caches are shared and don't refetch redundantly when navigating + // between the two pages. + const { data: subscriptionsData } = useQuery({ + queryKey: ['subscriptions-list'], + queryFn: subscriptionApi.getSubscriptions, + }); + const nonTrialSubs = (subscriptionsData?.subscriptions ?? []).filter((sub) => !sub.is_trial); + + const sbpQueries = useQueries({ + queries: nonTrialSubs.map((sub) => ({ + queryKey: ['sbp-recurring', sub.id], + queryFn: () => subscriptionApi.getSbpRecurring(sub.id), + retry: false, + })), + }); + + // No section at all when nothing is bound: either the feature is off + // (every query 403s) or none of the subscriptions has an active binding. + const sbpBindings: SbpBinding[] = nonTrialSubs.reduce((acc, sub, index) => { + const info = sbpQueries[index]?.data; + if (info && info.status !== 'none') { + acc.push({ sub, info }); + } + return acc; + }, []); + + const [unlinkingSubId, setUnlinkingSubId] = useState(null); + const confirmUnlinkSbp = useDestructiveConfirm(); + + const handleUnlinkSbp = async (subId: number) => { + if (unlinkingSubId !== null) return; + const confirmed = await confirmUnlinkSbp( + t('subscription.sbpRecurring.confirmCancel'), + t('subscription.sbpRecurring.cancel'), + ); + if (!confirmed) return; + setUnlinkingSubId(subId); + try { + await subscriptionApi.cancelSbpRecurring(subId); + await queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subId] }); + showToast({ + type: 'success', + title: t('subscription.sbpRecurring.cancelled'), + message: '', + duration: 3000, + }); + } catch (error) { + console.error('Failed to unlink SBP binding:', error); + showToast({ + type: 'error', + title: t('common.error'), + message: '', + duration: 3000, + }); + } finally { + setUnlinkingSubId(null); + } + }; + return ( // key: remount the container when loading resolves — stagger orchestration // runs once on mount, so cards arriving from the API later would otherwise @@ -184,6 +266,62 @@ export default function SavedCards() { ) : null} + + {/* SBP (Platega) recurring bindings — convenience mirror of the + per-subscription block on the Subscription page. Rendered only + when at least one non-trial subscription has an active binding; + hidden entirely when the feature is off or nothing is bound. */} + {sbpBindings.length > 0 && ( + + +

+ {t('balance.savedCards.sbpSection')} +

+
+ {sbpBindings.map(({ sub, info }) => { + const statusKey = sbpStatusLabelKey(info.status); + return ( +
+
+ 🔁 +
+
+ {sub.tariff_name || `#${sub.id}`} +
+
+ {t('balance.savedCards.sbpBinding')} + {statusKey ? ` · ${t(statusKey)}` : ''} + {info.next_charge_at + ? ` · ${t('subscription.sbpRecurring.nextCharge', { + date: new Date(info.next_charge_at).toLocaleDateString(uiLocale(), { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }), + })}` + : ''} +
+
+
+ +
+ ); + })} +
+
+
+ )} ); } diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 21cba8b..444095e 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -28,13 +28,21 @@ import { DownloadIcon, TrashIcon, } from '../components/icons'; -import { useHaptic } from '../platform'; +import { useHaptic, usePlatform } from '../platform'; import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { getErrorMessage, getInsufficientBalanceError, getFlagEmoji, } from '../utils/subscriptionHelpers'; +import { openPaymentUrl } from '../utils/openPaymentUrl'; +import { useToast } from '../components/Toast'; +import { + isSbpFeatureDisabledError, + sbpIntervalLabelKey, + sbpUiState, + type SbpUiState, +} from '../utils/sbpRecurring'; import Twemoji from 'react-twemoji'; import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet'; import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet'; @@ -194,6 +202,8 @@ export default function Subscription() { const { isDark } = useTheme(); const g = getGlassColors(isDark); const haptic = useHaptic(); + const { openLink, platform } = usePlatform(); + const { showToast } = useToast(); const [copied, setCopied] = useState(false); const [showDeleteSheet, setShowDeleteSheet] = useState(false); const destructiveConfirm = useDestructiveConfirm(); @@ -290,12 +300,90 @@ export default function Subscription() { const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; + // SBP (Platega) recurring auto-payment status. Polls every 8s while a + // payment is PENDING (waiting for bank-app confirmation) so the UI flips + // to 'active'/'past_due' without a manual refresh; stops polling otherwise. + const sbpQuery = useQuery({ + queryKey: ['sbp-recurring', subscriptionId], + queryFn: () => subscriptionApi.getSbpRecurring(subscriptionId), + enabled: !!subscription && !subscription.is_trial, + retry: false, + refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false), + }); + const sbpInfo = sbpQuery.data; + // 403 with a specific detail means the feature itself is disabled on the + // backend — distinct from "not resolved yet" or "other error", both of + // which must fail quiet (render nothing) rather than flash the 'off' state. + const sbpFeatureDisabled = isSbpFeatureDisabledError(sbpQuery.error); + const sbpUiStateValue: SbpUiState = + sbpInfo !== undefined || sbpFeatureDisabled + ? sbpUiState(sbpInfo, sbpFeatureDisabled) + : 'hidden'; + + const enableSbpMutation = useMutation({ + mutationFn: () => subscriptionApi.enableSbpRecurring(subscriptionId), + onSuccess: (data) => { + if (data.redirect_url) { + openPaymentUrl(data.redirect_url, platform, openLink); + } + queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] }); + // Backend flips autopay_enabled off when SBP auto-pay is enabled. + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); + }, + onError: (error: unknown) => { + const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data + ?.detail; + showToast({ + type: 'error', + title: typeof detail === 'string' ? detail : t('subscription.sbpRecurring.enableError'), + message: '', + duration: 3000, + }); + }, + }); + + const cancelSbpMutation = useMutation({ + mutationFn: () => subscriptionApi.cancelSbpRecurring(subscriptionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); + showToast({ + type: 'success', + title: t('subscription.sbpRecurring.cancelled'), + message: '', + duration: 3000, + }); + }, + onError: (error: unknown) => { + const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data + ?.detail; + showToast({ + type: 'error', + title: typeof detail === 'string' ? detail : t('subscription.sbpRecurring.cancelError'), + message: '', + duration: 3000, + }); + }, + }); + + const handleCancelSbp = async () => { + const confirmed = await destructiveConfirm( + t('subscription.sbpRecurring.confirmCancel'), + t('subscription.sbpRecurring.cancel'), + ); + if (!confirmed) return; + cancelSbpMutation.mutate(); + }; + const autopayMutation = useMutation({ mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); + // Enabling balance-autopay cancels SBP auto-pay server-side — refresh so + // the SBP block doesn't keep showing a now-stale 'active'/'pending' state. + queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] }); }, }); @@ -1076,6 +1164,122 @@ export default function Subscription() {
)} + + {/* ─── SBP Recurring Auto-payment ─── + Sibling of the autopay toggle above, guarded ONLY by + is_trial + uiState — daily-tariff subscriptions must see + this block too (backend supports a day-interval charge). */} + {!subscription.is_trial && sbpUiStateValue !== 'hidden' && ( +
+ {/* Заголовок и статус слева, компактное действие справа — + зеркально соседнему тогглу «Автопродление». На мобиле + кнопка падает вниз на всю ширину (w-full sm:w-auto). */} +
+
+
+ {t('subscription.sbpRecurring.title')} +
+ + {sbpUiStateValue === 'off' && ( +
+ {t('subscription.sbpRecurring.autopayHint')} +
+ )} + {sbpUiStateValue === 'pending' && ( +
+ {t('subscription.sbpRecurring.statusPending')} +
+ )} + {sbpUiStateValue === 'active' && sbpInfo && ( + <> +
+ {t('subscription.sbpRecurring.amountPerInterval', { + amount: formatAmount((sbpInfo.amount_kopeks ?? 0) / 100), + interval: t(sbpIntervalLabelKey(sbpInfo.interval)), + })} +
+ {sbpInfo.next_charge_at && ( +
+ {t('subscription.sbpRecurring.nextCharge', { + date: new Date(sbpInfo.next_charge_at).toLocaleDateString( + uiLocale(), + { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }, + ), + })} +
+ )} + + )} + {sbpUiStateValue === 'past_due' && ( +
+ {t('subscription.sbpRecurring.statusPastDue')} +
+ )} +
+ +
+ {sbpUiStateValue === 'off' && ( + + )} + + {sbpUiStateValue === 'pending' && ( + <> + {sbpInfo?.redirect_url && ( + + )} + + + )} + + {(sbpUiStateValue === 'active' || sbpUiStateValue === 'past_due') && ( + + )} +
+
+
+ )}
); })() diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index d9dc868..1e3f8ce 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -286,6 +286,12 @@ export default function SubscriptionPurchase() { tariff={selectedTariff} subscriptionId={subscriptionId} balanceKopeks={purchaseOptions?.balance_kopeks} + sbpPurchaseEnabled={ + isTariffsMode && + purchaseOptions !== undefined && + 'platega_recurrent_enabled' in purchaseOptions && + purchaseOptions.platega_recurrent_enabled === true + } onBack={() => { setShowTariffPurchase(false); setSelectedTariff(null); diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 4bda888..90f0306 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -1,5 +1,5 @@ import { uiLocale } from '@/utils/uiLocale'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; @@ -9,13 +9,14 @@ import { infoApi } from '../api/info'; import { useAuthStore } from '../store/auth'; import { logger } from '../utils/logger'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; -import type { TicketDetail } from '../types'; +import type { SupportConfig, TicketDetail } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons'; import { usePlatform } from '@/platform'; import { linkifyText } from '../utils/linkify'; +import { resolveSupportContact } from '../utils/supportContact'; const log = logger.createLogger('Support'); @@ -36,6 +37,19 @@ export default function Support() { const isAdmin = useAuthStore((state) => state.isAdmin); const queryClient = useQueryClient(); const { openTelegramLink, openLink } = usePlatform(); + + const openSupportContact = useCallback( + (config: SupportConfig) => { + const target = resolveSupportContact(config); + if (!target) return; + if (target.kind === 'external') { + openLink(target.url, { tryInstantView: false }); + } else { + openTelegramLink(target.url); + } + }, + [openLink, openTelegramLink], + ); const [selectedTicket, setSelectedTicket] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [newTitle, setNewTitle] = useState(''); @@ -199,65 +213,29 @@ export default function Support() { if (supportConfig && !supportConfig.tickets_enabled) { log.debug('Tickets disabled, config:', supportConfig); + // Куда и чем открывать контакт — один резолв на весь блок. null → открывать + // нечего (пустой/битый конфиг), и кнопку тогда не рендерим вовсе. + const contact = resolveSupportContact(supportConfig); + const getSupportMessage = () => { log.debug('Getting support message for type:', supportConfig.support_type); - if (supportConfig.support_type === 'profile') { - const supportUsername = supportConfig.support_username || '@support'; - log.debug('Opening profile:', supportUsername); - return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), - message: t('support.contactSupport', { username: supportUsername }), - buttonText: t('support.contactUs'), - buttonAction: () => { - log.debug('Button clicked, opening:', supportUsername); - - // Extract username without @ - const username = supportUsername.startsWith('@') - ? supportUsername.slice(1) - : supportUsername; - - const webUrl = `https://t.me/${username}`; - log.debug('Web URL:', webUrl); - - // Use platform's openTelegramLink - openTelegramLink(webUrl); - }, - }; - } + const title = isAdmin ? t('support.ticketsDisabled') : t('support.title'); if (supportConfig.support_type === 'url' && supportConfig.support_url) { return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), + title, message: t('support.useExternalLink'), buttonText: t('support.openSupport'), - buttonAction: () => { - openLink(supportConfig.support_url!, { tryInstantView: false }); - }, }; } - // Fallback: contact support (should not normally happen if config is correct) + // profile и любой fallback — контакт в телеграме const supportUsername = supportConfig.support_username || '@support'; - log.debug('Fallback: Opening profile:', supportUsername); return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), + title, message: t('support.contactSupport', { username: supportUsername }), buttonText: t('support.contactUs'), - buttonAction: () => { - log.debug('Fallback button clicked, opening:', supportUsername); - - // Extract username without @ - const username = supportUsername.startsWith('@') - ? supportUsername.slice(1) - : supportUsername; - - const webUrl = `https://t.me/${username}`; - log.debug('Fallback opening URL:', webUrl); - - // Use platform's openTelegramLink - openTelegramLink(webUrl); - }, }; }; @@ -271,9 +249,11 @@ export default function Support() {

{supportMessage.title}

{supportMessage.message}

- + {contact && ( + + )}
); @@ -352,33 +332,30 @@ export default function Support() { {/* Contact support card for "both" mode — self-animated: mounts after the config query resolves, when the parent stagger orchestration has already finished and would leave it stuck at opacity 0 */} - {supportConfig?.support_type === 'both' && supportConfig.support_username && ( - - -
-
- + {supportConfig?.support_type === 'both' && + supportConfig.support_username && + resolveSupportContact(supportConfig) && ( + + +
+
+ +
+
+
{t('support.contactUs')}
+
{supportConfig.support_username}
+
-
-
{t('support.contactUs')}
-
{supportConfig.support_username}
-
-
- - - - )} + + + + )} {/* Tickets List */} diff --git a/src/providers/WebSocketContext.ts b/src/providers/WebSocketContext.ts index 6c4cbb1..8a23823 100644 --- a/src/providers/WebSocketContext.ts +++ b/src/providers/WebSocketContext.ts @@ -21,6 +21,9 @@ export interface WSMessage { new_expires_at?: string; tariff_name?: string; days_left?: number; + // SBP recurring events + status?: string; + next_charge_at?: string | null; // Device purchase events devices_added?: number; new_device_limit?: number; diff --git a/src/types/index.ts b/src/types/index.ts index 9181884..4012737 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -370,6 +370,9 @@ export interface TariffsPurchaseOptions { has_subscription?: boolean; // Multi-tariff: all available tariffs already purchased all_tariffs_purchased?: boolean; + // СБП-оформление (Platega recurrent): показывать кнопку «Оформить с + // автооплатой СБП» рядом с покупкой с баланса + platega_recurrent_enabled?: boolean; } export interface ClassicPurchaseOptions { @@ -530,6 +533,8 @@ export interface SupportConfig { support_type: 'tickets' | 'profile' | 'url' | 'both'; support_url?: string | null; support_username?: string | null; + /** Резолвнутый контакт ведёт в Telegram, а не на внешний хелпдеск. */ + contact_is_telegram?: boolean; } // Paginated response @@ -647,6 +652,15 @@ export interface SavedCardsResponse { recurrent_enabled: boolean; } +// Platega SBP recurring auto-payment status for a subscription +export interface SbpRecurringInfo { + status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' + interval?: number; // 1=day,2=week,3=month,4=year + amount_kopeks?: number; + next_charge_at?: string | null; + redirect_url?: string | null; +} + // Ticket notifications types export interface TicketNotification { id: number; diff --git a/src/utils/api-error.test.ts b/src/utils/api-error.test.ts new file mode 100644 index 0000000..bb6c5bb --- /dev/null +++ b/src/utils/api-error.test.ts @@ -0,0 +1,59 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import { describe, expect, it } from 'vitest'; +import { getApiErrorMessage, isEndpointMissingError } from './api-error'; + +function axiosErrorWithStatus(status: number, detail?: unknown): AxiosError { + const headers = new AxiosHeaders(); + const config = { headers }; + return new AxiosError( + 'Request failed', + 'ERR_BAD_REQUEST', + config, + {}, + { + status, + statusText: '', + headers, + config, + data: detail === undefined ? {} : { detail }, + }, + ); +} + +describe('isEndpointMissingError', () => { + it('returns true for an axios 404', () => { + expect(isEndpointMissingError(axiosErrorWithStatus(404))).toBe(true); + }); + + it('returns false for other axios statuses', () => { + expect(isEndpointMissingError(axiosErrorWithStatus(403))).toBe(false); + expect(isEndpointMissingError(axiosErrorWithStatus(500))).toBe(false); + }); + + it('returns false for non-axios errors', () => { + expect(isEndpointMissingError(new Error('boom'))).toBe(false); + expect(isEndpointMissingError(undefined)).toBe(false); + expect(isEndpointMissingError(null)).toBe(false); + }); +}); + +describe('getApiErrorMessage', () => { + it('returns string detail from an axios error', () => { + expect(getApiErrorMessage(axiosErrorWithStatus(400, 'Code must not be empty'), 'fb')).toBe( + 'Code must not be empty', + ); + }); + + it('joins pydantic validation errors', () => { + const err = axiosErrorWithStatus(422, [ + { loc: ['body', 'name'], msg: 'field required' }, + { loc: ['body', 'days'], msg: 'must be positive' }, + ]); + expect(getApiErrorMessage(err, 'fb')).toBe('name: field required; days: must be positive'); + }); + + it('falls back for non-axios errors and missing detail', () => { + expect(getApiErrorMessage(new Error('boom'), 'fb')).toBe('fb'); + expect(getApiErrorMessage(axiosErrorWithStatus(500), 'fb')).toBe('fb'); + }); +}); diff --git a/src/utils/api-error.ts b/src/utils/api-error.ts index b33e2ff..8e9f850 100644 --- a/src/utils/api-error.ts +++ b/src/utils/api-error.ts @@ -1,5 +1,15 @@ import axios from 'axios'; +/** + * True when the backend answered 404 on the route itself — for cabinet + * endpoints that means the bot build is too old to have them (e.g. the + * deep-link auth routes exist only since bot v3.33.0), as opposed to a + * missing entity inside a handler. + */ +export function isEndpointMissingError(err: unknown): boolean { + return axios.isAxiosError(err) && err.response?.status === 404; +} + export function getApiErrorMessage(err: unknown, fallback: string): string { if (axios.isAxiosError(err)) { const detail = err.response?.data?.detail; diff --git a/src/utils/channelBlockingMessage.test.ts b/src/utils/channelBlockingMessage.test.ts new file mode 100644 index 0000000..6b2e118 --- /dev/null +++ b/src/utils/channelBlockingMessage.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { customChannelMessage } from './channelBlockingMessage'; + +describe('customChannelMessage', () => { + it('returns null for empty values', () => { + expect(customChannelMessage(undefined)).toBeNull(); + expect(customChannelMessage(null)).toBeNull(); + expect(customChannelMessage('')).toBeNull(); + expect(customChannelMessage(' ')).toBeNull(); + }); + + it('returns null for the hardcoded backend default message', () => { + expect( + customChannelMessage('Please subscribe to the required channels to continue'), + ).toBeNull(); + }); + + it('ignores surrounding whitespace when matching the backend default', () => { + expect( + customChannelMessage(' Please subscribe to the required channels to continue '), + ).toBeNull(); + }); + + it('passes through a custom message', () => { + expect(customChannelMessage('Подпишитесь на наш канал @example')).toBe( + 'Подпишитесь на наш канал @example', + ); + }); + + it('trims a custom message', () => { + expect(customChannelMessage(' custom ')).toBe('custom'); + }); +}); diff --git a/src/utils/channelBlockingMessage.ts b/src/utils/channelBlockingMessage.ts new file mode 100644 index 0000000..8e61289 --- /dev/null +++ b/src/utils/channelBlockingMessage.ts @@ -0,0 +1,18 @@ +/** + * The backend hardcodes this English message in the 403 + * `channel_subscription_required` payload (app/cabinet/dependencies.py). It is + * not admin-configurable and not localized, so showing it verbatim leaks + * English into every locale. Treat it as "no custom message" and let the UI + * fall back to its own i18n string. + */ +const BACKEND_DEFAULT_MESSAGES = new Set(['Please subscribe to the required channels to continue']); + +/** + * Returns the backend-provided channel-subscription message only when it is a + * real custom message; `null` for empty values and known backend defaults. + */ +export function customChannelMessage(message: string | null | undefined): string | null { + const trimmed = message?.trim(); + if (!trimmed || BACKEND_DEFAULT_MESSAGES.has(trimmed)) return null; + return trimmed; +} diff --git a/src/utils/displayName.test.ts b/src/utils/displayName.test.ts new file mode 100644 index 0000000..00ea739 --- /dev/null +++ b/src/utils/displayName.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { displayName } from './displayName'; + +describe('displayName', () => { + it('returns empty string for missing user', () => { + expect(displayName(undefined)).toBe(''); + expect(displayName(null)).toBe(''); + }); + + it('combines first_name and last_name', () => { + expect(displayName({ first_name: 'Иван', last_name: 'Петров' })).toBe('Иван Петров'); + expect(displayName({ first_name: 'Иван', last_name: null })).toBe('Иван'); + expect(displayName({ first_name: null, last_name: 'Петров' })).toBe('Петров'); + }); + + it('falls back to username when there is no name', () => { + expect(displayName({ first_name: null, last_name: null, username: 'ivan' })).toBe('ivan'); + }); + + it('falls back to telegram_id when there is no name and no username', () => { + expect(displayName({ first_name: null, username: null, telegram_id: 123456 })).toBe('#123456'); + }); + + it('falls back to the email local part for email-only users', () => { + expect( + displayName({ + first_name: null, + last_name: null, + username: null, + telegram_id: null, + email: 'vasya@gmail.com', + }), + ).toBe('vasya'); + }); + + it('prefers telegram_id over email', () => { + expect(displayName({ telegram_id: 123456, email: 'vasya@gmail.com' })).toBe('#123456'); + }); + + it('returns empty string when every source is empty', () => { + expect( + displayName({ + first_name: ' ', + last_name: null, + username: null, + telegram_id: null, + email: null, + }), + ).toBe(''); + }); +}); diff --git a/src/utils/displayName.ts b/src/utils/displayName.ts index d79412f..c7c62ab 100644 --- a/src/utils/displayName.ts +++ b/src/utils/displayName.ts @@ -3,21 +3,28 @@ * * Why: single-letter first_name (e.g., "О") looked confusing alone ("Добро пожаловать, О!"). * Combining with last_name makes truncated/short first names readable. + * + * Email/OAuth registration does not collect a name, so for such users every + * Telegram-derived field is null — fall back to the email local part + * ("vasya@gmail.com" → "vasya") to avoid an empty name in greetings. */ export interface NameSource { first_name?: string | null; last_name?: string | null; username?: string | null; telegram_id?: number | null; + email?: string | null; } export function displayName(user?: NameSource | null): string { if (!user) return ''; const fullName = [user.first_name, user.last_name] - .filter((part): part is string => Boolean(part && part.trim())) + .filter((part): part is string => Boolean(part?.trim())) .join(' '); if (fullName) return fullName; if (user.username) return user.username; if (user.telegram_id) return `#${user.telegram_id}`; + const emailLocalPart = user.email?.trim().split('@')[0]; + if (emailLocalPart) return emailLocalPart; return ''; } diff --git a/src/utils/openAppScheme.test.ts b/src/utils/openAppScheme.test.ts new file mode 100644 index 0000000..c9177a4 --- /dev/null +++ b/src/utils/openAppScheme.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { openAppScheme } from './openAppScheme'; + +// openAppScheme touches window/document/navigator; the suite runs in a plain node +// environment, so stub just enough of the DOM to observe which launch path is taken. +interface FakeIframe { + style: { display: string }; + src: string; + remove: () => void; +} + +function setup(nav: { userAgent?: string; platform?: string; maxTouchPoints?: number }) { + const location = { href: '' }; + const createdIframes: FakeIframe[] = []; + + vi.stubGlobal('navigator', { + userAgent: nav.userAgent ?? '', + platform: nav.platform ?? '', + maxTouchPoints: nav.maxTouchPoints ?? 0, + }); + vi.stubGlobal('window', { + location, + setTimeout: () => 0, + }); + vi.stubGlobal('document', { + body: { appendChild: () => undefined }, + createElement: () => { + const iframe: FakeIframe = { style: { display: '' }, src: '', remove: () => undefined }; + createdIframes.push(iframe); + return iframe; + }, + }); + + return { location, createdIframes }; +} + +describe('openAppScheme', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('navigates directly for http(s) links (no iframe)', () => { + const { location, createdIframes } = setup({ userAgent: 'Mozilla/5.0 (Android)' }); + openAppScheme('https://example.com/sub'); + expect(location.href).toBe('https://example.com/sub'); + expect(createdIframes).toHaveLength(0); + }); + + it('opens a custom scheme via top-level navigation on iOS (iPhone), not via iframe', () => { + const { location, createdIframes } = setup({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1', + }); + openAppScheme('incy://import/https://sp.example.com/abc'); + // iOS ignores iframe scheme launches — must be a real top-level navigation. + expect(location.href).toBe('incy://import/https://sp.example.com/abc'); + expect(createdIframes).toHaveLength(0); + }); + + it('treats touch-capable iPadOS (reports as Mac) as iOS', () => { + const { location, createdIframes } = setup({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605.1', + platform: 'MacIntel', + maxTouchPoints: 5, + }); + openAppScheme('happ://add/https://sp.example.com/abc'); + expect(location.href).toBe('happ://add/https://sp.example.com/abc'); + expect(createdIframes).toHaveLength(0); + }); + + it('uses a contained iframe for custom schemes on Android (keeps page alive)', () => { + const { location, createdIframes } = setup({ + userAgent: 'Mozilla/5.0 (Linux; Android 13) Chrome/120', + }); + openAppScheme('incy://import/https://sp.example.com/abc'); + // Android in-app browsers would paint a full-page error on a top-level nav. + expect(location.href).toBe(''); + expect(createdIframes).toHaveLength(1); + expect(createdIframes[0].src).toBe('incy://import/https://sp.example.com/abc'); + }); + + it('treats a non-touch Mac (desktop Safari) as non-iOS', () => { + const { createdIframes } = setup({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605.1', + platform: 'MacIntel', + maxTouchPoints: 0, + }); + openAppScheme('incy://import/https://sp.example.com/abc'); + expect(createdIframes).toHaveLength(1); + }); +}); + +describe('openAppScheme guard (defense-in-depth)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + 'javascript:alert(1)', + 'javascript://%0aalert(1)', + 'data:text/html,', + 'vbscript://x', + 'file:///etc/passwd', + 'intent://scan/#Intent;end', + 'no-scheme-at-all', + ])('never navigates for dangerous input (%s)', (url) => { + const { location, createdIframes } = setup({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1', + }); + openAppScheme(url); + expect(location.href).toBe(''); + expect(createdIframes).toHaveLength(0); + }); + + it('still opens valid app schemes after the guard', () => { + const { location } = setup({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1', + }); + openAppScheme('happ://add/https://sp.example.com/abc'); + expect(location.href).toBe('happ://add/https://sp.example.com/abc'); + }); +}); diff --git a/src/utils/openAppScheme.ts b/src/utils/openAppScheme.ts index 6f60bc1..8fa3add 100644 --- a/src/utils/openAppScheme.ts +++ b/src/utils/openAppScheme.ts @@ -1,24 +1,59 @@ /** - * Launch a custom-scheme app deep link (happ://, v2rayng://, vless://, …) without - * crashing the page inside in-app browsers. + * Launch a custom-scheme app deep link (happ://, incy://, v2rayng://, vless://, …) + * without crashing the page inside in-app browsers, and — crucially — in a way that + * actually opens the app on iOS. * - * Why not `window.location.href = scheme`: a programmatic top-level navigation to a - * scheme the WebView can't resolve renders a full-page error — on Android in-app - * browsers (Telegram/Yandex/…) `net::ERR_UNKNOWN_URL_SCHEME`, on iOS it silently does - * nothing — which destroys the fallback UI (Telegram bug #654272). A hidden