diff --git a/src/App.tsx b/src/App.tsx index 33f220f..6fbd3ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,11 +27,13 @@ import { MaintenanceScreen, ChannelSubscriptionScreen, BlacklistedScreen, + AccountDeletedScreen, } from './components/blocking'; import { ErrorBoundary } from './components/ErrorBoundary'; import { PermissionRoute } from '@/components/auth/PermissionRoute'; import { saveReturnUrl } from './utils/token'; import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; +import { useSiteVerification } from './hooks/useSiteVerification'; // Auth pages - load immediately (small) import Login from './pages/Login'; import TelegramCallback from './pages/TelegramCallback'; @@ -218,6 +220,10 @@ function BlockingOverlay() { return ; } + if (blockingType === 'account_deleted') { + return ; + } + return null; } @@ -229,6 +235,9 @@ function LegacySubscriptionRedirect() { function App() { useAnalyticsCounters(); + // Pulls site-verification tokens (Antilopay apay-tag etc.) from the bot + // backend and injects matching tags into document.head. + useSiteVerification(); return ( <> diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index c348e46..388a79b 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -719,7 +719,13 @@ export const adminUsersApi = { userId: number, subscriptionId?: number, ): Promise<{ - devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[]; + devices: { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; + }[]; total: number; device_limit: number; }> => { @@ -729,6 +735,19 @@ export const adminUsersApi = { return response.data; }, + // Set / clear local device alias on behalf of the user (admin override). + renameUserDevice: async ( + userId: number, + hwid: string, + name: string | null, + ): Promise<{ hwid: string; local_name: string | null }> => { + const response = await apiClient.patch( + `/cabinet/admin/users/${userId}/devices/${encodeURIComponent(hwid)}/name`, + { name }, + ); + return response.data; + }, + // Delete single device deleteUserDevice: async ( userId: number, diff --git a/src/api/client.ts b/src/api/client.ts index 5b0fb78..20ee92f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -152,6 +152,13 @@ export interface BlacklistedError { message: string; } +export interface AccountDeletedError { + code: 'account_deleted'; + message: string; + bot_username?: string; + telegram_deep_link?: string; +} + export function isMaintenanceError( error: unknown, ): error is { response: { status: 503; data: { detail: MaintenanceError } } } { @@ -179,6 +186,14 @@ export function isBlacklistedError( return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted'; } +export function isAccountDeletedError( + error: unknown, +): error is { response: { status: 403; data: { detail: AccountDeletedError } } } { + if (!error || typeof error !== 'object') return false; + const err = error as AxiosError<{ detail: AccountDeletedError }>; + return err.response?.status === 403 && err.response?.data?.detail?.code === 'account_deleted'; +} + apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { @@ -211,6 +226,21 @@ apiClient.interceptors.response.use( return Promise.reject(error); } + if (isAccountDeletedError(error)) { + const detail = (error.response?.data as { detail: AccountDeletedError }).detail; + // Surface the deleted-account screen. The auth flow (initData login) + // is allowed to auto-revive; this branch is for token-bearing + // sessions where the user is already in the cabinet but their row + // got marked DELETED out-of-band, and for password-only logins + // that can't be silently revived. + useBlockingStore.getState().setAccountDeleted({ + message: detail.message, + bot_username: detail.bot_username, + telegram_deep_link: detail.telegram_deep_link, + }); + return Promise.reject(error); + } + if (error.response?.status === 401 && !originalRequest._retry) { const requestUrl = originalRequest.url || ''; diff --git a/src/api/siteVerification.ts b/src/api/siteVerification.ts new file mode 100644 index 0000000..c11686b --- /dev/null +++ b/src/api/siteVerification.ts @@ -0,0 +1,16 @@ +import { apiClient } from './client'; + +export interface SiteVerification { + apay_tag: string | null; +} + +/** + * Public, unauthenticated endpoint — payment-provider crawlers (Antilopay) + * need to be able to read these values to validate site ownership. + */ +export const siteVerificationApi = { + async get(): Promise { + const { data } = await apiClient.get('/cabinet/public/site-verification'); + return data; + }, +}; diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 832954d..c80bad4 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -264,6 +264,7 @@ export const subscriptionApi = { platform: string; device_model: string; created_at: string | null; + local_name?: string | null; }>; total: number; device_limit: number; @@ -275,6 +276,27 @@ export const subscriptionApi = { return response.data; }, + /** + * Persist a user-local alias for a device. Pass an empty/null `name` to + * clear the alias. Returns the final `local_name` after server-side + * normalization (trimmed + length-capped). + * + * Scope is per-(user, hwid), so the same alias appears across all of + * the user's subscriptions in multi-tariff mode. + */ + renameDevice: async ( + hwid: string, + name: string | null, + subscriptionId?: number, + ): Promise<{ hwid: string; local_name: string | null }> => { + const response = await apiClient.patch( + `/cabinet/subscription/devices/${encodeURIComponent(hwid)}/name`, + { name }, + withSubId(subscriptionId), + ); + return response.data; + }, + deleteDevice: async ( hwid: string, subscriptionId?: number, diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 03af869..63ae244 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -43,27 +43,52 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp const [isVisible, setIsVisible] = useState(false); const tooltipRef = useRef(null); + const onCompleteRef = useRef(onComplete); + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + const step = steps[currentStep]; - // Find and highlight target element useEffect(() => { - const findTarget = () => { + let cancelled = false; + let attempts = 0; + const maxAttempts = 6; + const isLastStep = currentStep === steps.length - 1; + + setIsVisible(false); + setTargetRect(null); + + const tryFind = () => { + if (cancelled) return; const target = document.querySelector(`[data-onboarding="${step.target}"]`); if (target) { const rect = target.getBoundingClientRect(); setTargetRect(rect); - - // Scroll element into view if needed target.scrollIntoView({ behavior: 'smooth', block: 'center' }); - - // Delay visibility for smooth animation - setTimeout(() => setIsVisible(true), 100); + window.setTimeout(() => { + if (!cancelled) setIsVisible(true); + }, 100); + return; + } + attempts += 1; + if (attempts < maxAttempts) { + window.setTimeout(tryFind, 200); + return; + } + if (isLastStep) { + onCompleteRef.current(); + } else { + setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)); } }; - setIsVisible(false); - const timer = setTimeout(findTarget, 300); - return () => clearTimeout(timer); + const timer = window.setTimeout(tryFind, 300); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [step.target]); // Recalculate position on resize/scroll @@ -170,13 +195,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp return createPortal(
{/* Spotlight */} -
+
{/* Tooltip */}
{/* Progress indicator */}
@@ -222,8 +256,8 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
- {/* Click handler to advance on target click */} - {targetRect && ( + {/* Click handler to advance on target click — only when overlay is fully visible */} + {targetRect && isVisible && (
state.accountDeletedInfo); + + const deepLink = info?.telegram_deep_link?.trim() || null; + // Route through the platform adapter, not raw window.open. Inside the + // Telegram WebView, window.open is intercepted by the client and the + // new-tab fallback is blocked on most platforms (Android, iOS). The + // TelegramAdapter dispatches to the WebApp SDK's openTelegramLink in + // Telegram and falls back to window.open in the standalone web build. + const handleOpenBot = () => { + if (deepLink) { + openTelegramLink(deepLink); + } + }; + + const handleRetry = () => { + // Reload rather than just clearing the store: we want a fresh + // network round-trip against the (hopefully now-revived) row. + useBlockingStore.getState().clearBlocking(); + window.location.reload(); + }; + + return ( +
+
+
+
+ +
+
+ +

{t('blocking.accountDeleted.title')}

+ +

{t('blocking.accountDeleted.description')}

+ +
+ {deepLink && ( + + )} + +
+ +

{t('blocking.accountDeleted.hint')}

+
+
+ ); +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts index d72b87d..94bced2 100644 --- a/src/components/blocking/index.ts +++ b/src/components/blocking/index.ts @@ -1,3 +1,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen'; export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'; export { default as BlacklistedScreen } from './BlacklistedScreen'; +export { default as AccountDeletedScreen } from './AccountDeletedScreen'; diff --git a/src/constants/devices.ts b/src/constants/devices.ts new file mode 100644 index 0000000..5cfcc17 --- /dev/null +++ b/src/constants/devices.ts @@ -0,0 +1,8 @@ +/** + * Maximum length of a user-set local device alias. + * + * Mirrors `ALIAS_MAX_LENGTH` in the bot's `user_device_aliases` table. + * Keep this value in sync with `app/database/crud/user_device_alias.py` + * (Python side enforces it at the DB column width AND at the API layer). + */ +export const DEVICE_ALIAS_MAX_LENGTH = 64; diff --git a/src/hooks/useSiteVerification.ts b/src/hooks/useSiteVerification.ts new file mode 100644 index 0000000..935dade --- /dev/null +++ b/src/hooks/useSiteVerification.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { siteVerificationApi } from '../api/siteVerification'; + +/** + * Fetches the configured site-verification tokens from the bot backend + * and injects the matching `` tags into `document.head`. + * + * Used by Antilopay's verification flow (lk.antilopay.com → Проект → + * Верификация → Способ 1: мета-тег). Once the merchant copies the + * Antilopay-provided value into `ANTILOPAY_APAY_VERIFICATION_TAG`, + * the cabinet will start rendering `` + * automatically — no rebuild required. + * + * The hook is no-op on backend errors (verification is a best-effort + * concern and must never block the cabinet from rendering). + */ +export function useSiteVerification(): void { + useEffect(() => { + let cancelled = false; + + siteVerificationApi + .get() + .then((data) => { + if (cancelled) return; + if (data.apay_tag) { + upsertMetaTag('apay-tag', data.apay_tag); + } else { + removeMetaTag('apay-tag'); + } + }) + .catch(() => { + // Silent fail — verification meta is best-effort, doesn't block UI. + }); + + return () => { + cancelled = true; + }; + }, []); +} + +function upsertMetaTag(name: string, content: string): void { + // Security note: `setAttribute('content', value)` stores `value` as a plain + // string attribute — the browser does NOT parse it as HTML. Even if a + // compromised backend returned ``, it would + // be stored literally inside `content="..."` and never executed. DO NOT + // switch to `innerHTML` / string concatenation into — that would + // make this an XSS sink. + let tag = document.head.querySelector(`meta[name="${name}"]`); + if (!tag) { + tag = document.createElement('meta'); + tag.setAttribute('name', name); + document.head.appendChild(tag); + } + tag.setAttribute('content', content); +} + +function removeMetaTag(name: string): void { + const tag = document.head.querySelector(`meta[name="${name}"]`); + if (tag) { + tag.remove(); + } +} diff --git a/src/locales/en.json b/src/locales/en.json index dbb137c..1627841 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -468,6 +468,11 @@ "confirmDeleteAllDevices": "Delete all devices?", "deviceDeleted": "Device deleted", "allDevicesDeleted": "All devices deleted", + "renameDevice": "Rename", + "renameDeviceSave": "Save", + "renameDeviceCancel": "Cancel", + "renameDevicePlaceholder": "Device name", + "deviceRenamed": "Device name updated", "platform": "Platform", "model": "Model", "connectedAt": "Connected", @@ -3176,7 +3181,10 @@ "none": "No connected devices", "resetAll": "Reset all", "deleted": "Device removed", - "allDeleted": "All devices reset" + "allDeleted": "All devices reset", + "rename": "Rename", + "renameSave": "Save", + "renamed": "Device name updated" }, "referral": { "title": "Referral program", @@ -4658,6 +4666,13 @@ "defaultMessage": "Your account has been blocked.", "reason": "Reason", "contactSupport": "If you believe this is an error, please contact support." + }, + "accountDeleted": { + "title": "Account deactivated", + "description": "Your account was deactivated after a long period of inactivity. To restore access, open the bot in Telegram and send /start.", + "openBot": "Open the bot", + "retry": "I pressed /start, retry", + "hint": "After running /start, return here and tap «Retry»." } }, "merge": { diff --git a/src/locales/fa.json b/src/locales/fa.json index c26fb6d..f55f2f2 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -454,6 +454,11 @@ "month": "ماه", "myDevices": "دستگاه‌های من", "noDevices": "هیچ دستگاه متصلی نیست", + "renameDevice": "تغییر نام", + "renameDeviceSave": "ذخیره", + "renameDeviceCancel": "لغو", + "renameDevicePlaceholder": "نام دستگاه", + "deviceRenamed": "نام دستگاه به‌روزرسانی شد", "perExtraDevice": "/ دستگاه اضافی", "perMonth": "/ماه", "purchasedTraffic": "ترافیک خریداری شده", @@ -2667,7 +2672,10 @@ "none": "دستگاه متصلی وجود ندارد", "resetAll": "بازنشانی همه", "deleted": "دستگاه حذف شد", - "allDeleted": "همه دستگاه‌ها بازنشانی شدند" + "allDeleted": "همه دستگاه‌ها بازنشانی شدند", + "rename": "تغییر نام", + "renameSave": "ذخیره", + "renamed": "نام دستگاه به‌روزرسانی شد" }, "referral": { "title": "برنامه ارجاع", @@ -4195,6 +4203,13 @@ "defaultMessage": "حساب شما مسدود شده است.", "reason": "دلیل", "contactSupport": "اگر فکر می‌کنید این اشتباه است، با پشتیبانی تماس بگیرید." + }, + "accountDeleted": { + "title": "حساب غیرفعال شد", + "description": "حساب شما به دلیل عدم فعالیت طولانی غیرفعال شده است. برای بازیابی دسترسی، ربات را در تلگرام باز کرده و دستور /start را ارسال کنید.", + "openBot": "باز کردن ربات", + "retry": "من /start را زدم، دوباره امتحان کن", + "hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید." } }, "merge": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 394b2a0..da3ccc8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -492,6 +492,11 @@ "confirmDeleteAllDevices": "Удалить все устройства?", "deviceDeleted": "Устройство удалено", "allDevicesDeleted": "Все устройства удалены", + "renameDevice": "Переименовать", + "renameDeviceSave": "Сохранить", + "renameDeviceCancel": "Отмена", + "renameDevicePlaceholder": "Имя устройства", + "deviceRenamed": "Имя устройства обновлено", "platform": "Платформа", "model": "Модель", "connectedAt": "Подключено", @@ -3581,7 +3586,10 @@ "none": "Нет подключённых устройств", "resetAll": "Сбросить все", "deleted": "Устройство удалено", - "allDeleted": "Все устройства сброшены" + "allDeleted": "Все устройства сброшены", + "rename": "Переименовать", + "renameSave": "Сохранить", + "renamed": "Имя устройства обновлено" }, "referral": { "title": "Реферальная программа", @@ -5210,6 +5218,13 @@ "defaultMessage": "Ваш аккаунт заблокирован.", "reason": "Причина", "contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку." + }, + "accountDeleted": { + "title": "Аккаунт деактивирован", + "description": "Ваш аккаунт был деактивирован за длительную неактивность. Чтобы восстановить доступ, откройте бота в Telegram и отправьте команду /start.", + "openBot": "Открыть бота", + "retry": "Я нажал /start, попробовать снова", + "hint": "После /start вернитесь сюда и нажмите «Попробовать снова»." } }, "merge": { diff --git a/src/locales/zh.json b/src/locales/zh.json index dcac98d..728d600 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -454,6 +454,11 @@ "month": "月", "myDevices": "我的设备", "noDevices": "没有已连接的设备", + "renameDevice": "重命名", + "renameDeviceSave": "保存", + "renameDeviceCancel": "取消", + "renameDevicePlaceholder": "设备名称", + "deviceRenamed": "设备名称已更新", "perExtraDevice": "/ 额外设备", "perMonth": "/月", "purchasedTraffic": "已购流量", @@ -2666,7 +2671,10 @@ "none": "没有已连接的设备", "resetAll": "重置全部", "deleted": "设备已删除", - "allDeleted": "所有设备已重置" + "allDeleted": "所有设备已重置", + "rename": "重命名", + "renameSave": "保存", + "renamed": "设备名称已更新" }, "referral": { "title": "推荐计划", @@ -4076,6 +4084,13 @@ "defaultMessage": "您的帐户已被封锁。", "reason": "原因", "contactSupport": "如果您认为这是错误,请联系客服。" + }, + "accountDeleted": { + "title": "账户已停用", + "description": "由于长期未活动,您的账户已被停用。请在 Telegram 中打开机器人并发送 /start 以恢复访问。", + "openBot": "打开机器人", + "retry": "我已按 /start,重试", + "hint": "运行 /start 后,返回此处并点击「重试」。" } }, "banSystem": { diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 3c6b53d..4f040f6 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -12,6 +12,7 @@ import { } from '../api/adminRemnawave'; import { usePlatform } from '../platform/hooks/usePlatform'; import { formatUptime } from '../utils/format'; +import { getFlagEmoji } from '../utils/subscriptionHelpers'; import Twemoji from 'react-twemoji'; import { ServerIcon, @@ -46,48 +47,9 @@ const formatBytes = (bytes: number): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -const getCountryFlag = (code: string | null | undefined): string => { - if (!code) return '🌍'; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - UK: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - NO: '🇳🇴', - PL: '🇵🇱', - TR: '🇹🇷', - JP: '🇯🇵', - SG: '🇸🇬', - HK: '🇭🇰', - KR: '🇰🇷', - AU: '🇦🇺', - CA: '🇨🇦', - CH: '🇨🇭', - AT: '🇦🇹', - IT: '🇮🇹', - ES: '🇪🇸', - BR: '🇧🇷', - IN: '🇮🇳', - AE: '🇦🇪', - IL: '🇮🇱', - KZ: '🇰🇿', - UA: '🇺🇦', - CZ: '🇨🇿', - RO: '🇷🇴', - LV: '🇱🇻', - LT: '🇱🇹', - EE: '🇪🇪', - BG: '🇧🇬', - HU: '🇭🇺', - MD: '🇲🇩', - }; - return codeMap[code.toUpperCase()] || code; -}; +// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback +// сохранён для случая пустого кода (важно для UI-плейсхолдеров). +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍'; interface StatCardProps { label: string; diff --git a/src/pages/AdminRemnawaveSquadDetail.tsx b/src/pages/AdminRemnawaveSquadDetail.tsx index d59ee53..c13ad9f 100644 --- a/src/pages/AdminRemnawaveSquadDetail.tsx +++ b/src/pages/AdminRemnawaveSquadDetail.tsx @@ -5,49 +5,12 @@ import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave'; import { AdminBackButton } from '../components/admin'; import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons'; import Twemoji from 'react-twemoji'; -// Country flags helper -const getCountryFlag = (code: string | null | undefined): string => { - if (!code) return '🌍'; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - UK: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - NO: '🇳🇴', - PL: '🇵🇱', - TR: '🇹🇷', - JP: '🇯🇵', - SG: '🇸🇬', - HK: '🇭🇰', - KR: '🇰🇷', - AU: '🇦🇺', - CA: '🇨🇦', - CH: '🇨🇭', - AT: '🇦🇹', - IT: '🇮🇹', - ES: '🇪🇸', - BR: '🇧🇷', - IN: '🇮🇳', - AE: '🇦🇪', - IL: '🇮🇱', - KZ: '🇰🇿', - UA: '🇺🇦', - CZ: '🇨🇿', - RO: '🇷🇴', - LV: '🇱🇻', - LT: '🇱🇹', - EE: '🇪🇪', - BG: '🇧🇬', - HU: '🇭🇺', - MD: '🇲🇩', - }; - return codeMap[code.toUpperCase()] || code; -}; +import { getFlagEmoji } from '../utils/subscriptionHelpers'; + +// Country flag helper. Алгоритмический ISO 3166-1 alpha-2 → regional indicator, +// чтобы не плодить хардкод-словари (исторически у каждого экрана был свой +// неполный, и EE/MX/AR не покрывались). При отсутствии кода — глобус. +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍'; export default function AdminRemnawaveSquadDetail() { const { t } = useTranslation(); diff --git a/src/pages/AdminServerEdit.tsx b/src/pages/AdminServerEdit.tsx index 8204c15..a3c7026 100644 --- a/src/pages/AdminServerEdit.tsx +++ b/src/pages/AdminServerEdit.tsx @@ -7,39 +7,7 @@ import { AdminBackButton } from '../components/admin'; import { ServerIcon } from '../components/icons'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import Twemoji from 'react-twemoji'; - -// Country flags (simple emoji mapping) -const getCountryFlag = (code: string | null): string => { - if (!code) return ''; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - PL: '🇵🇱', - CZ: '🇨🇿', - AT: '🇦🇹', - CH: '🇨🇭', - UA: '🇺🇦', - KZ: '🇰🇿', - JP: '🇯🇵', - KR: '🇰🇷', - SG: '🇸🇬', - HK: '🇭🇰', - CA: '🇨🇦', - AU: '🇦🇺', - BR: '🇧🇷', - IN: '🇮🇳', - TR: '🇹🇷', - IL: '🇮🇱', - AE: '🇦🇪', - }; - return codeMap[code.toUpperCase()] || code; -}; +import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers'; export default function AdminServerEdit() { const { t } = useTranslation(); diff --git a/src/pages/AdminServers.tsx b/src/pages/AdminServers.tsx index f04bead..9080cde 100644 --- a/src/pages/AdminServers.tsx +++ b/src/pages/AdminServers.tsx @@ -20,37 +20,7 @@ const BackIcon = () => ( ); // Country flags (simple emoji mapping) -const getCountryFlag = (code: string | null): string => { - if (!code) return ''; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - PL: '🇵🇱', - CZ: '🇨🇿', - AT: '🇦🇹', - CH: '🇨🇭', - UA: '🇺🇦', - KZ: '🇰🇿', - JP: '🇯🇵', - KR: '🇰🇷', - SG: '🇸🇬', - HK: '🇭🇰', - CA: '🇨🇦', - AU: '🇦🇺', - BR: '🇧🇷', - IN: '🇮🇳', - TR: '🇹🇷', - IL: '🇮🇱', - AE: '🇦🇪', - }; - return codeMap[code.toUpperCase()] || code; -}; +import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers'; export default function AdminServers() { const { t } = useTranslation(); diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index b28c07d..c64fe86 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -18,6 +18,7 @@ import { type TrafficEnrichmentData, } from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { getFlagEmoji as _sharedGetFlagEmoji } from '../utils/subscriptionHelpers'; // ============ TanStack Table module augmentation ============ @@ -40,14 +41,9 @@ const formatBytes = (bytes: number): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -const getFlagEmoji = (countryCode: string): string => { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode - .toUpperCase() - .split('') - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -}; +// Локальная обёртка над общим helper'ом, чтобы внутренние сигнатуры (string) +// оставались как были и call-sites не меняли. +const getFlagEmoji = (countryCode: string): string => _sharedGetFlagEmoji(countryCode); const formatCurrency = (kopeks: number): string => { const rubles = kopeks / 100; diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 163beff..6ae22c6 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; +import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices'; import { useCurrency } from '../hooks/useCurrency'; import { useNotify } from '../platform/hooks/useNotify'; import { @@ -24,51 +25,14 @@ import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { linkifyText } from '../utils/linkify'; +import { getFlagEmoji } from '../utils/subscriptionHelpers'; // ============ Helpers ============ -const getCountryFlag = (code: string | null | undefined): string => { - if (!code) return ''; - const codeMap: Record = { - RU: '\u{1F1F7}\u{1F1FA}', - US: '\u{1F1FA}\u{1F1F8}', - DE: '\u{1F1E9}\u{1F1EA}', - NL: '\u{1F1F3}\u{1F1F1}', - GB: '\u{1F1EC}\u{1F1E7}', - UK: '\u{1F1EC}\u{1F1E7}', - FR: '\u{1F1EB}\u{1F1F7}', - FI: '\u{1F1EB}\u{1F1EE}', - SE: '\u{1F1F8}\u{1F1EA}', - NO: '\u{1F1F3}\u{1F1F4}', - PL: '\u{1F1F5}\u{1F1F1}', - TR: '\u{1F1F9}\u{1F1F7}', - JP: '\u{1F1EF}\u{1F1F5}', - SG: '\u{1F1F8}\u{1F1EC}', - HK: '\u{1F1ED}\u{1F1F0}', - KR: '\u{1F1F0}\u{1F1F7}', - AU: '\u{1F1E6}\u{1F1FA}', - CA: '\u{1F1E8}\u{1F1E6}', - CH: '\u{1F1E8}\u{1F1ED}', - AT: '\u{1F1E6}\u{1F1F9}', - IT: '\u{1F1EE}\u{1F1F9}', - ES: '\u{1F1EA}\u{1F1F8}', - BR: '\u{1F1E7}\u{1F1F7}', - IN: '\u{1F1EE}\u{1F1F3}', - AE: '\u{1F1E6}\u{1F1EA}', - IL: '\u{1F1EE}\u{1F1F1}', - KZ: '\u{1F1F0}\u{1F1FF}', - UA: '\u{1F1FA}\u{1F1E6}', - CZ: '\u{1F1E8}\u{1F1FF}', - RO: '\u{1F1F7}\u{1F1F4}', - LV: '\u{1F1F1}\u{1F1FB}', - LT: '\u{1F1F1}\u{1F1F9}', - EE: '\u{1F1EA}\u{1F1EA}', - BG: '\u{1F1E7}\u{1F1EC}', - HU: '\u{1F1ED}\u{1F1FA}', - MD: '\u{1F1F2}\u{1F1E9}', - }; - return codeMap[code.toUpperCase()] || code; -}; +// Алгоритмический ISO 3166-1 alpha-2 → regional indicator (вместо хардкод-словаря, +// который не покрывал все страны: например, EE раньше плыл сырым текстом в одних +// местах, MX/AR/EG до сих пор отсутствуют в других). Единая точка истины в utils. +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code); // ============ Icons ============ @@ -375,11 +339,20 @@ export default function AdminUserDetail() { // Devices const [devices, setDevices] = useState< - { hwid: string; platform: string; device_model: string; created_at: string | null }[] + { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; + }[] >([]); const [devicesTotal, setDevicesTotal] = useState(0); const [deviceLimit, setDeviceLimit] = useState(0); const [devicesLoading, setDevicesLoading] = useState(false); + const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); + const [editingDeviceName, setEditingDeviceName] = useState(''); + const [renameSaving, setRenameSaving] = useState(false); // Gifts const [giftsData, setGiftsData] = useState(null); @@ -797,6 +770,29 @@ export default function AdminUserDetail() { } }; + // Admin renames a device on behalf of the user. Empty/whitespace input + // clears the alias and falls back to the platform/model default. + const handleRenameDevice = async (hwid: string) => { + if (!userId) return; + setRenameSaving(true); + // Snapshot inputs BEFORE the await so a fast click on another device + // mid-flight doesn't smuggle a different alias into this hwid's request. + const snapshotName = editingDeviceName.trim(); + try { + await adminUsersApi.renameUserDevice(userId, hwid, snapshotName || null); + notify.success(t('admin.users.detail.devices.renamed', 'Имя устройства обновлено')); + // Reset edit state only if user is still on the saved row. + setEditingDeviceHwid((current) => (current === hwid ? null : current)); + await loadDevices(); + } catch (err) { + const apiMessage = (err as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail; + notify.error(apiMessage || t('admin.users.userActions.error'), t('common.error')); + } finally { + setRenameSaving(false); + } + }; + const handleResetDevices = async () => { if (!userId) return; setActionLoading(true); @@ -2464,44 +2460,183 @@ export default function AdminUserDetail() {
) : devices.length > 0 ? (
- {devices.map((device) => ( -
-
-
- {device.platform || device.device_model || device.hwid.slice(0, 12)} -
-
- {device.device_model && device.platform && ( - {device.device_model} + {devices.map((device) => { + const isEditing = editingDeviceHwid === device.hwid; + // Display priority: alias \u2192 model \u2192 platform \u2192 hwid prefix. + const displayName = + (device.local_name && device.local_name.trim()) || + device.platform || + device.device_model || + device.hwid.slice(0, 12); + + return ( +
+
+ {isEditing ? ( + setEditingDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRenameDevice(device.hwid); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + } + }} + className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50" + /> + ) : ( +
+ {displayName} +
)} - {device.hwid.slice(0, 8)}... - {device.created_at && ( - - {new Date(device.created_at).toLocaleDateString(locale)} - +
+ {device.device_model && device.platform && ( + {device.device_model} + )} + {device.hwid.slice(0, 8)}... + {device.created_at && ( + + {new Date(device.created_at).toLocaleDateString(locale)} + + )} +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + )}
- -
- ))} + ); + })}
) : (
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 66ae194..1ba9ff6 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -246,8 +246,8 @@ export default function Dashboard() { }, [t, subscription]); const handleOnboardingComplete = () => { - setShowOnboarding(false); completeOnboarding(); + setShowOnboarding(false); }; return ( diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 4e367cf..e3eee59 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; +import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices'; import { WebBackButton } from '../components/WebBackButton'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { usePlatform } from '../platform'; @@ -308,6 +309,30 @@ export default function Subscription() { }, }); + // Local device alias (rename) state. Only one device can be in edit-mode + // at a time — `editingDeviceHwid` doubles as both the toggle and the + // identifier of the row being edited. + const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); + const [editingDeviceName, setEditingDeviceName] = useState(''); + + const renameDeviceMutation = useMutation({ + mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) => + subscriptionApi.renameDevice(hwid, name, subscriptionId), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); + // Soft success-tap, like other mutations on this page. + haptic.notification('success'); + // Не сбрасываем edit-state, если пользователь уже перешёл на другой + // девайс пока шёл запрос — иначе теряем его новый input. Имя не чистим + // безусловно: оно либо принадлежит уже другому девайсу (нужно сохранить), + // либо инпут уже закрылся (значение не отображается). + setEditingDeviceHwid((current) => (current === variables.hwid ? null : current)); + }, + onError: () => { + haptic.notification('error'); + }, + }); + // Pause subscription mutation const pauseMutation = useMutation({ mutationFn: () => subscriptionApi.togglePause(subscriptionId), @@ -493,6 +518,9 @@ export default function Subscription() { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); + // RemnaWave resets device HWIDs on revoke — make sure the cabinet + // re-reads the now-empty device list instead of showing the stale cache. + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); haptic.notification('success'); localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString()); setRevokeCooldown(900); @@ -2403,73 +2431,203 @@ export default function Subscription() { ? `${devicesData.total} · ∞` : `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
- {devicesData.devices.map((device) => ( -
-
-
-
+
+
- - + +
+
+ {isEditing ? ( + setEditingDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const trimmed = editingDeviceName.trim(); + renameDeviceMutation.mutate({ + hwid: device.hwid, + name: trimmed || null, + }); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + } + }} + className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1" + style={{ + background: g.trackBg, + boxShadow: `inset 0 0 0 1px ${g.innerBorder}`, + }} + /> + ) : ( +
+ {displayName} +
+ )} +
+ {device.platform} + + {device.hwid.slice(0, 8).toUpperCase()} + +
+
-
-
- {device.device_model || device.platform} -
-
- {device.platform} - - {device.hwid.slice(0, 8).toUpperCase()} - -
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )}
- -
- ))} + ); + })}
) : (
diff --git a/src/store/blocking.ts b/src/store/blocking.ts index cd4d53a..160f371 100644 --- a/src/store/blocking.ts +++ b/src/store/blocking.ts @@ -1,6 +1,11 @@ import { create } from 'zustand'; -export type BlockingType = 'maintenance' | 'channel_subscription' | 'blacklisted' | null; +export type BlockingType = + | 'maintenance' + | 'channel_subscription' + | 'blacklisted' + | 'account_deleted' + | null; interface MaintenanceInfo { message: string; @@ -29,15 +34,26 @@ interface BlacklistedInfo { message: string; } +interface AccountDeletedInfo { + /** Backend-provided localized message. We may override with i18n key on render. */ + message: string; + /** Bot username (without @) for building the Telegram deep link client-side as fallback. */ + bot_username?: string; + /** Full Telegram deep-link URL (`https://t.me/?start=revive`). Empty when bot is unconfigured. */ + telegram_deep_link?: string; +} + interface BlockingState { blockingType: BlockingType; maintenanceInfo: MaintenanceInfo | null; channelInfo: ChannelSubscriptionInfo | null; blacklistedInfo: BlacklistedInfo | null; + accountDeletedInfo: AccountDeletedInfo | null; setMaintenance: (info: MaintenanceInfo) => void; setChannelSubscription: (info: ChannelSubscriptionInfo) => void; setBlacklisted: (info: BlacklistedInfo) => void; + setAccountDeleted: (info: AccountDeletedInfo) => void; clearBlocking: () => void; } @@ -46,6 +62,7 @@ export const useBlockingStore = create((set) => ({ maintenanceInfo: null, channelInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, setMaintenance: (info) => set({ @@ -53,6 +70,7 @@ export const useBlockingStore = create((set) => ({ maintenanceInfo: info, channelInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, }), setChannelSubscription: (info) => @@ -61,6 +79,7 @@ export const useBlockingStore = create((set) => ({ channelInfo: info, maintenanceInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, }), setBlacklisted: (info) => @@ -69,6 +88,16 @@ export const useBlockingStore = create((set) => ({ blacklistedInfo: info, maintenanceInfo: null, channelInfo: null, + accountDeletedInfo: null, + }), + + setAccountDeleted: (info) => + set({ + blockingType: 'account_deleted', + accountDeletedInfo: info, + maintenanceInfo: null, + channelInfo: null, + blacklistedInfo: null, }), clearBlocking: () => @@ -77,5 +106,6 @@ export const useBlockingStore = create((set) => ({ maintenanceInfo: null, channelInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, }), })); diff --git a/src/types/index.ts b/src/types/index.ts index 5b1abde..2208030 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -141,6 +141,12 @@ export interface Device { platform: string; device_model: string; created_at: string | null; + /** + * User-set local alias persisted in the bot DB (`user_device_aliases`). + * `null` when the user hasn't renamed the device — clients fall back + * to `device_model` / `platform` for display. + */ + local_name?: string | null; } export interface DevicesResponse { diff --git a/src/utils/subscriptionHelpers.ts b/src/utils/subscriptionHelpers.ts index 8be7f94..944ba69 100644 --- a/src/utils/subscriptionHelpers.ts +++ b/src/utils/subscriptionHelpers.ts @@ -32,9 +32,12 @@ export const getInsufficientBalanceError = ( return null; }; -export const getFlagEmoji = (countryCode: string): string => { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode +export const getFlagEmoji = (countryCode: string | null | undefined): string => { + // Trim + длина строго 2 буквы — иначе Unicode regional indicators не дадут флаг. + // Принимаем null/undefined чтобы вызывающие коду не приходилось страховаться. + const code = (countryCode ?? '').trim(); + if (code.length !== 2 || !/^[A-Za-z]{2}$/.test(code)) return ''; + const codePoints = code .toUpperCase() .split('') .map((char) => 127397 + char.charCodeAt(0));