Merge pull request #429 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-05-16 06:12:38 +03:00
committed by GitHub
25 changed files with 866 additions and 321 deletions

View File

@@ -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 <BlacklistedScreen />;
}
if (blockingType === 'account_deleted') {
return <AccountDeletedScreen />;
}
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 <meta> tags into document.head.
useSiteVerification();
return (
<>

View File

@@ -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,

View File

@@ -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 || '';

View File

@@ -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<SiteVerification> {
const { data } = await apiClient.get<SiteVerification>('/cabinet/public/site-verification');
return data;
},
};

View File

@@ -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,

View File

@@ -43,27 +43,52 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
const [isVisible, setIsVisible] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(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(
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
{/* Spotlight */}
<div className="onboarding-spotlight" style={getSpotlightStyle()} />
<div
className="onboarding-spotlight"
style={{
...getSpotlightStyle(),
pointerEvents: isVisible ? 'auto' : 'none',
}}
/>
{/* Tooltip */}
<div
ref={tooltipRef}
className={`onboarding-tooltip tooltip-${step.placement}`}
style={getTooltipStyle()}
style={{
...getTooltipStyle(),
pointerEvents: isVisible ? 'auto' : 'none',
}}
>
{/* Progress indicator */}
<div className="mb-4 flex items-center gap-1.5">
@@ -222,8 +256,8 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
</div>
</div>
{/* Click handler to advance on target click */}
{targetRect && (
{/* Click handler to advance on target click — only when overlay is fully visible */}
{targetRect && isVisible && (
<div
className="absolute cursor-pointer"
style={{

View File

@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform';
import { useBlockingStore } from '../../store/blocking';
/**
* Full-screen block shown when the backend returns
* `403 {detail: {code: "account_deleted", ...}}`.
*
* Triggered for two situations:
* * Token-bearing requests where the user row was flipped to DELETED
* by the inactivity-cleanup job out-of-band.
* * Email/password login of a previously-DELETED account where we
* have no Telegram signature to auto-revive on the server side.
*
* Recovery: pressing /start in the bot triggers the existing revival
* flow (handlers/start.py), which flips status back to ACTIVE. The
* "Retry" button reloads the SPA so the next request observes the new
* status and clears the block.
*/
export default function AccountDeletedScreen() {
const { t } = useTranslation();
const { openTelegramLink } = usePlatform();
const info = useBlockingStore((state) => 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 (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div className="w-full max-w-md text-center">
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg
className="h-12 w-12 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.accountDeleted.title')}</h1>
<p className="mb-6 text-lg text-gray-400">{t('blocking.accountDeleted.description')}</p>
<div className="space-y-3">
{deepLink && (
<button
type="button"
onClick={handleOpenBot}
className="block w-full rounded-xl bg-blue-600 px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.openBot')}
</button>
)}
<button
type="button"
onClick={handleRetry}
className="block w-full rounded-xl bg-dark-800 px-6 py-3 text-base font-medium text-gray-200 transition-colors hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.retry')}
</button>
</div>
<p className="mt-8 text-sm text-gray-500">{t('blocking.accountDeleted.hint')}</p>
</div>
</div>
);
}

View File

@@ -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';

8
src/constants/devices.ts Normal file
View File

@@ -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;

View File

@@ -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 `<meta>` 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 `<meta name="apay-tag" content="...">`
* 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 `</meta><script>alert(1)</script>`, it would
// be stored literally inside `content="..."` and never executed. DO NOT
// switch to `innerHTML` / string concatenation into <head> — that would
// make this an XSS sink.
let tag = document.head.querySelector<HTMLMetaElement>(`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<HTMLMetaElement>(`meta[name="${name}"]`);
if (tag) {
tag.remove();
}
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<string, string> = {
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;

View File

@@ -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<string, string> = {
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();

View File

@@ -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<string, string> = {
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();

View File

@@ -20,37 +20,7 @@ const BackIcon = () => (
);
// Country flags (simple emoji mapping)
const getCountryFlag = (code: string | null): string => {
if (!code) return '';
const codeMap: Record<string, string> = {
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();

View File

@@ -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;

View File

@@ -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<string, string> = {
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<string | null>(null);
const [editingDeviceName, setEditingDeviceName] = useState('');
const [renameSaving, setRenameSaving] = useState(false);
// Gifts
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(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() {
</div>
) : devices.length > 0 ? (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.hwid}
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-dark-200">
{device.platform || device.device_model || device.hwid.slice(0, 12)}
</div>
<div className="flex items-center gap-2 text-[10px] text-dark-500">
{device.device_model && device.platform && (
<span>{device.device_model}</span>
{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 (
<div
key={device.hwid}
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
>
<div className="min-w-0 flex-1">
{isEditing ? (
<input
type="text"
autoFocus
value={editingDeviceName}
maxLength={DEVICE_ALIAS_MAX_LENGTH}
placeholder={
device.platform ||
device.device_model ||
device.hwid.slice(0, 12)
}
onChange={(e) => 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"
/>
) : (
<div className="truncate text-xs font-medium text-dark-200">
{displayName}
</div>
)}
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
{device.created_at && (
<span>
{new Date(device.created_at).toLocaleDateString(locale)}
</span>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-dark-500">
{device.device_model && device.platform && (
<span>{device.device_model}</span>
)}
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
{device.created_at && (
<span>
{new Date(device.created_at).toLocaleDateString(locale)}
</span>
)}
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
{isEditing ? (
<>
<button
type="button"
onClick={() => handleRenameDevice(device.hwid)}
disabled={renameSaving}
className="rounded-lg px-2 py-1 text-success-400 transition-all hover:bg-success-500/15 disabled:opacity-50"
title={t(
'admin.users.detail.devices.renameSave',
t(
'common.save',
'\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C',
),
)}
aria-label={t(
'admin.users.detail.devices.renameSave',
'\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C',
)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingDeviceHwid(null);
setEditingDeviceName('');
}}
disabled={renameSaving}
className="rounded-lg px-2 py-1 text-dark-500 transition-all hover:bg-dark-700 disabled:opacity-50"
title={t(
'common.cancel',
'\u041E\u0442\u043C\u0435\u043D\u0430',
)}
aria-label={t(
'common.cancel',
'\u041E\u0442\u043C\u0435\u043D\u0430',
)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</>
) : (
<>
<button
type="button"
onClick={() => {
setEditingDeviceHwid(device.hwid);
setEditingDeviceName(device.local_name || '');
}}
className="rounded-lg px-2 py-1 text-dark-500 transition-all hover:bg-accent-500/15 hover:text-accent-400"
title={t(
'admin.users.detail.devices.rename',
'\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C',
)}
aria-label={t(
'admin.users.detail.devices.rename',
'\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C',
)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
</svg>
</button>
<button
onClick={() =>
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
handleDeleteDevice(device.hwid),
)
}
disabled={actionLoading}
className={`rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
confirmingAction === `deleteDevice_${device.hwid}`
? 'bg-error-500 text-white'
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
}`}
>
{confirmingAction === `deleteDevice_${device.hwid}`
? '?'
: '\u00D7'}
</button>
</>
)}
</div>
</div>
<button
onClick={() =>
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
handleDeleteDevice(device.hwid),
)
}
disabled={actionLoading}
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
confirmingAction === `deleteDevice_${device.hwid}`
? 'bg-error-500 text-white'
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
}`}
>
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
</button>
</div>
))}
);
})}
</div>
) : (
<div className="py-2 text-center text-xs text-dark-500">

View File

@@ -246,8 +246,8 @@ export default function Dashboard() {
}, [t, subscription]);
const handleOnboardingComplete = () => {
setShowOnboarding(false);
completeOnboarding();
setShowOnboarding(false);
};
return (

View File

@@ -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<string | null>(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 })}`}
</div>
{devicesData.devices.map((device) => (
<div
key={device.hwid}
className="flex items-center justify-between rounded-[12px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="flex items-center gap-3">
<div
className="flex h-9 w-9 items-center justify-center rounded-[10px]"
style={{ background: g.trackBg }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={g.textSecondary}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{devicesData.devices.map((device) => {
const isEditing = editingDeviceHwid === device.hwid;
// Display priority: user alias → device model → platform.
const displayName =
(device.local_name && device.local_name.trim()) ||
device.device_model ||
device.platform;
return (
<div
key={device.hwid}
className="flex items-center justify-between rounded-[12px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
style={{ background: g.trackBg }}
>
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={g.textSecondary}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
</div>
<div className="min-w-0 flex-1">
{isEditing ? (
<input
type="text"
autoFocus
value={editingDeviceName}
maxLength={DEVICE_ALIAS_MAX_LENGTH}
placeholder={device.device_model || device.platform}
onChange={(e) => 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}`,
}}
/>
) : (
<div className="truncate text-sm font-semibold text-dark-50">
{displayName}
</div>
)}
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-dark-50/30">
<span className="truncate">{device.platform}</span>
<span className="font-mono text-dark-50/20">
{device.hwid.slice(0, 8).toUpperCase()}
</span>
</div>
</div>
</div>
<div>
<div className="text-sm font-semibold text-dark-50">
{device.device_model || device.platform}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-dark-50/30">
<span>{device.platform}</span>
<span className="font-mono text-dark-50/20">
{device.hwid.slice(0, 8).toUpperCase()}
</span>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
{isEditing ? (
<>
<button
type="button"
onClick={() => {
const trimmed = editingDeviceName.trim();
renameDeviceMutation.mutate({
hwid: device.hwid,
name: trimmed || null,
});
}}
disabled={renameDeviceMutation.isPending}
className="p-2 transition-colors"
style={{ color: g.textSecondary }}
title={t('subscription.renameDeviceSave', 'Сохранить')}
aria-label={t('subscription.renameDeviceSave', 'Сохранить')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingDeviceHwid(null);
setEditingDeviceName('');
}}
disabled={renameDeviceMutation.isPending}
className="p-2 transition-colors"
style={{ color: g.textFaint }}
title={t('subscription.renameDeviceCancel', 'Отмена')}
aria-label={t('subscription.renameDeviceCancel', 'Отмена')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</>
) : (
<>
<button
type="button"
onClick={() => {
setEditingDeviceHwid(device.hwid);
setEditingDeviceName(device.local_name || '');
}}
className="p-2 transition-colors"
style={{ color: g.textFaint }}
title={t('subscription.renameDevice', 'Переименовать')}
aria-label={t('subscription.renameDevice', 'Переименовать')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
</svg>
</button>
<button
type="button"
onClick={() => {
if (confirm(t('subscription.confirmDeleteDevice'))) {
deleteDeviceMutation.mutate(device.hwid);
}
}}
disabled={deleteDeviceMutation.isPending}
className="p-2 transition-colors"
style={{ color: g.textFaint }}
title={t('subscription.deleteDevice')}
aria-label={t('subscription.deleteDevice')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</>
)}
</div>
</div>
<button
onClick={() => {
if (confirm(t('subscription.confirmDeleteDevice'))) {
deleteDeviceMutation.mutate(device.hwid);
}
}}
disabled={deleteDeviceMutation.isPending}
className="p-2 transition-colors"
style={{ color: g.textFaint }}
title={t('subscription.deleteDevice')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
))}
);
})}
</div>
) : (
<div className="py-8 text-center text-[12px] text-dark-50/25">

View File

@@ -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/<bot>?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<BlockingState>((set) => ({
maintenanceInfo: null,
channelInfo: null,
blacklistedInfo: null,
accountDeletedInfo: null,
setMaintenance: (info) =>
set({
@@ -53,6 +70,7 @@ export const useBlockingStore = create<BlockingState>((set) => ({
maintenanceInfo: info,
channelInfo: null,
blacklistedInfo: null,
accountDeletedInfo: null,
}),
setChannelSubscription: (info) =>
@@ -61,6 +79,7 @@ export const useBlockingStore = create<BlockingState>((set) => ({
channelInfo: info,
maintenanceInfo: null,
blacklistedInfo: null,
accountDeletedInfo: null,
}),
setBlacklisted: (info) =>
@@ -69,6 +88,16 @@ export const useBlockingStore = create<BlockingState>((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<BlockingState>((set) => ({
maintenanceInfo: null,
channelInfo: null,
blacklistedInfo: null,
accountDeletedInfo: null,
}),
}));

View File

@@ -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 {

View File

@@ -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));