- {/* 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() {