)}
+
+ {/* Only offer a way back if the widget actually works — if the
+ script failed there is nothing to go back to. */}
+ {!scriptFailed && (
+
+ )}
);
}
@@ -569,6 +596,17 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
@{botUsername}
+
+ {/* Manual opt-in: same deep-link flow used as the anti-block fallback,
+ offered here as an explicit alternative for users who'd rather
+ confirm in the bot than type a phone number into the widget. */}
+
);
}
diff --git a/src/locales/en.json b/src/locales/en.json
index b07b36d..8bd981e 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -207,6 +207,9 @@
"orOpenInApp": "Or open the bot in the app",
"loginFailed": "Login Failed",
"telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:",
+ "deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:",
+ "loginWithBot": "Login via bot (no phone number)",
+ "backToWidget": "Back to widget login",
"openBotToLogin": "Open bot to sign in",
"waitingForConfirmation": "Waiting for confirmation...",
"deepLinkExpired": "Link expired. Please try again.",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 080e88f..971a829 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -210,6 +210,9 @@
"orOpenInApp": "Или откройте бота в приложении",
"loginFailed": "Ошибка входа",
"telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:",
+ "deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:",
+ "loginWithBot": "Войти через бота (без номера телефона)",
+ "backToWidget": "Назад к входу через виджет",
"openBotToLogin": "Открыть бота для входа",
"waitingForConfirmation": "Ожидание подтверждения...",
"deepLinkExpired": "Ссылка истекла. Попробуйте снова.",
From 75424dba473925b99d993d0e52f33067f14125ce Mon Sep 17 00:00:00 2001
From: "Artem (OpenIN)"
Date: Mon, 3 Aug 2026 18:23:16 +0000
Subject: [PATCH 3/7] refactor(auth): consolidate three Telegram entry points
into two
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Drop the passive '@bot_username' link (opens the bot chat with no
auth purpose) for the common case — it's now redundant with the new
'Login via bot' button, which offers the same open-bot action plus
QR and actual authentication.
- Keep the referral deep link (bot start=) only when a
referralCode prop is present — that's a distinct registration flow
for not-yet-registered users, not a login method.
- Add an 'or' divider between the widget and the manual deep-link
button so the two remaining options read as equal alternatives
rather than a stack of similar-looking Telegram links.
- Shorten the deep-link button label to 'Login via bot' — the
no-phone-number framing is already implied and shown once the flow
starts.
No changes to the OIDC/widget button logic itself.
---
src/components/TelegramLoginButton.tsx | 36 +++++++++++++++-----------
src/locales/en.json | 2 +-
src/locales/ru.json | 2 +-
3 files changed, 23 insertions(+), 17 deletions(-)
diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx
index e388376..05d79de 100644
--- a/src/components/TelegramLoginButton.tsx
+++ b/src/components/TelegramLoginButton.tsx
@@ -578,33 +578,39 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
)}
-
-
{t('auth.orOpenInApp')}
+ {/* Referral deep link — only relevant for not-yet-registered users who
+ arrived via a referral link; the bot itself handles registering
+ them with the code attached. Hidden otherwise to avoid a third,
+ visually-identical "Telegram" entry point next to the two auth
+ methods below. */}
+ {referralCode && (
-
- @{botUsername}
+ {t('auth.orOpenInApp')} @{botUsername}
+ )}
+
+
+
+ {t('common.or')}
+
{/* Manual opt-in: same deep-link flow used as the anti-block fallback,
- offered here as an explicit alternative for users who'd rather
- confirm in the bot than type a phone number into the widget. */}
+ offered here as an explicit equal alternative to the widget for
+ users who'd rather confirm in the bot than type a phone number. */}
diff --git a/src/locales/en.json b/src/locales/en.json
index 8bd981e..80e72e4 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -208,7 +208,7 @@
"loginFailed": "Login Failed",
"telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:",
"deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:",
- "loginWithBot": "Login via bot (no phone number)",
+ "loginWithBot": "Login via bot",
"backToWidget": "Back to widget login",
"openBotToLogin": "Open bot to sign in",
"waitingForConfirmation": "Waiting for confirmation...",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 971a829..2feebe2 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -211,7 +211,7 @@
"loginFailed": "Ошибка входа",
"telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:",
"deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:",
- "loginWithBot": "Войти через бота (без номера телефона)",
+ "loginWithBot": "Войти через бота",
"backToWidget": "Назад к входу через виджет",
"openBotToLogin": "Открыть бота для входа",
"waitingForConfirmation": "Ожидание подтверждения...",
From eafc563128b68f49f0a1f03a30c899b527ce4734 Mon Sep 17 00:00:00 2001
From: Case211 <87642841+Case211@users.noreply.github.com>
Date: Tue, 4 Aug 2026 23:38:20 +0500
Subject: [PATCH 4/7] =?UTF-8?q?feat(users):=20=D0=BA=D0=BD=D0=BE=D0=BF?=
=?UTF-8?q?=D0=BA=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?=
=?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D0=B2=20?=
=?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
В мультитарифном режиме у пользователя несколько подписок, и убрать
лишнюю — например, отработавший триал — можно было только через экран
«Массовые действия». Во вкладке «Подписка» карточки подписки видны и
открываются, но удалить выбранную нечем.
Кнопка живёт в детальном виде подписки и требует подтверждения тем же
inline-механизмом, что и отмена автоплатежа; ключ подтверждения включает
id подписки, чтобы взведённое согласие не пережило переключение на
соседнюю. Активная платная подписка на сервере защищена от случайного
удаления, поэтому для неё запрос идёт с явным force — намерение админ
уже подтвердил.
---
src/api/adminUsers.ts | 13 +++++++
.../admin/userDetail/SubscriptionTab.tsx | 37 +++++++++++++++++++
src/locales/en.json | 6 ++-
src/locales/fa.json | 6 ++-
src/locales/ru.json | 6 ++-
src/locales/zh.json | 6 ++-
src/pages/AdminUserDetail.tsx | 19 ++++++++++
7 files changed, 89 insertions(+), 4 deletions(-)
diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts
index abd4863..4df5c37 100644
--- a/src/api/adminUsers.ts
+++ b/src/api/adminUsers.ts
@@ -518,6 +518,19 @@ export const adminUsersApi = {
return response.data;
},
+ // Delete one of the user's subscriptions (multi-tariff: trials pile up)
+ deleteSubscription: async (
+ userId: number,
+ subId: number,
+ force = false,
+ ): Promise<{ status: string }> => {
+ const response = await apiClient.delete(
+ `/cabinet/admin/users/${userId}/subscriptions/${subId}`,
+ { params: force ? { force: true } : undefined },
+ );
+ return response.data;
+ },
+
// Update status
updateStatus: async (
userId: number,
diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx
index a8b83c8..40e66de 100644
--- a/src/components/admin/userDetail/SubscriptionTab.tsx
+++ b/src/components/admin/userDetail/SubscriptionTab.tsx
@@ -131,6 +131,7 @@ export interface SubscriptionTabProps {
onRemoveTraffic: (purchaseId: number) => Promise;
onResetDevices: () => Promise;
onCancelSbpRecurring: () => Promise;
+ onDeleteSubscription: () => Promise;
onDeleteDevice: (hwid: string) => Promise;
onRenameDevice: (hwid: string) => Promise;
onLoadDevices: () => Promise;
@@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
onRemoveTraffic,
onResetDevices,
onCancelSbpRecurring,
+ onDeleteSubscription,
onDeleteDevice,
onRenameDevice,
onLoadDevices,
@@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
)}
+ {/* Delete this subscription — in multi-tariff mode spent trials
+ pile up in the card, and removing one used to be possible
+ only through the bulk-actions screen. */}
+ {hasPermission('users:subscription') && (
+
diff --git a/src/locales/en.json b/src/locales/en.json
index b07b36d..133b0af 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -3646,7 +3646,11 @@
"sbpCancelled": "SBP auto-payment disabled",
"sbpStatus_PENDING": "Pending",
"sbpStatus_ACTIVE": "Active",
- "sbpStatus_PAST_DUE": "Payment failed"
+ "sbpStatus_PAST_DUE": "Payment failed",
+ "deleteTitle": "Delete subscription",
+ "deleteHint": "The subscription and its devices will be removed permanently",
+ "deleteButton": "Delete subscription",
+ "deleted": "Subscription deleted"
},
"balance": {
"current": "Current balance",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index cbbb785..f343f7d 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -3105,7 +3105,11 @@
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
"sbpStatus_PENDING": "در انتظار",
"sbpStatus_ACTIVE": "فعال",
- "sbpStatus_PAST_DUE": "پرداخت ناموفق"
+ "sbpStatus_PAST_DUE": "پرداخت ناموفق",
+ "deleteTitle": "حذف اشتراک",
+ "deleteHint": "اشتراک و دستگاههای آن برای همیشه حذف میشوند",
+ "deleteButton": "حذف اشتراک",
+ "deleted": "اشتراک حذف شد"
},
"balance": {
"current": "موجودی فعلی",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 080e88f..cb13ebb 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -4043,7 +4043,11 @@
"sbpCancelled": "Автоплатёж по СБП отключён",
"sbpStatus_PENDING": "Ожидает подтверждения",
"sbpStatus_ACTIVE": "Активен",
- "sbpStatus_PAST_DUE": "Платёж не прошёл"
+ "sbpStatus_PAST_DUE": "Платёж не прошёл",
+ "deleteTitle": "Удаление подписки",
+ "deleteHint": "Подписка и её устройства будут удалены безвозвратно",
+ "deleteButton": "Удалить подписку",
+ "deleted": "Подписка удалена"
},
"balance": {
"current": "Текущий баланс",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 49b202d..797a62e 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -3104,7 +3104,11 @@
"sbpCancelled": "SBP 自动扣款已关闭",
"sbpStatus_PENDING": "待确认",
"sbpStatus_ACTIVE": "已启用",
- "sbpStatus_PAST_DUE": "扣款失败"
+ "sbpStatus_PAST_DUE": "扣款失败",
+ "deleteTitle": "删除订阅",
+ "deleteHint": "订阅及其设备将被永久删除",
+ "deleteButton": "删除订阅",
+ "deleted": "订阅已删除"
},
"balance": {
"current": "当前余额",
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx
index db7abb9..7902e06 100644
--- a/src/pages/AdminUserDetail.tsx
+++ b/src/pages/AdminUserDetail.tsx
@@ -662,6 +662,24 @@ export default function AdminUserDetail() {
}
};
+ const handleDeleteSubscription = async () => {
+ if (!userId || !selectedSub) return;
+ setActionLoading(true);
+ try {
+ // Активную платную подписку сервер по умолчанию бережёт — админ уже
+ // подтвердил намерение кнопкой, поэтому просим удалить именно её.
+ const force = Boolean(selectedSub.is_active) && !selectedSub.is_trial;
+ await adminUsersApi.deleteSubscription(userId, selectedSub.id, force);
+ notify.success(t('admin.users.detail.subscription.deleted'), t('common.success'));
+ setSubscriptionDetailView(false);
+ await loadUser();
+ } catch {
+ notify.error(t('admin.users.userActions.error'), t('common.error'));
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
const handleDisableUser = async () => {
if (!userId) return;
setActionLoading(true);
@@ -874,6 +892,7 @@ export default function AdminUserDetail() {
userSubscriptions={userSubscriptions}
selectedSub={selectedSub}
onCancelSbpRecurring={handleCancelSbpRecurring}
+ onDeleteSubscription={handleDeleteSubscription}
activeSubscriptionId={activeSubscriptionId}
onActiveSubscriptionChange={setActiveSubscriptionId}
subscriptionDetailView={subscriptionDetailView}
From 2d6f8d97546f7ca69540924aa0637f720500bef3 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Thu, 6 Aug 2026 02:11:14 +0300
Subject: [PATCH 5/7] =?UTF-8?q?fix(admin):=20=D0=B4=D0=BE=D0=BF=D0=BE?=
=?UTF-8?q?=D0=BB=D0=BD=D0=B8=D1=82=D1=8C=20=D1=82=D0=B8=D0=BF=20sort=5Fby?=
=?UTF-8?q?=20=D0=B8=20=D0=BA=D0=B8=D1=82=D0=B0=D0=B9=D1=81=D0=BA=D1=83?=
=?UTF-8?q?=D1=8E=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=D1=81=D0=BE?=
=?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Дополнения к PR #534 при мерже.
Юнион sort_by в adminUsers.ts не знал про subscription_end_date. Ошибки
компиляции нет только потому, что вызывающий код собирает params как
Record и приводит его каст-выражением, то есть юнион там
вообще не проверяется. Но это единственная запись о том, какие значения
принимает эндпоинт, и она разъехалась с реальностью.
Китайская строка «按到期» ставит после 按 глагол, тогда как все соседние
пункты — 按日期, 按余额, 按活跃度, 按消费 — существительные. Сам словарь
кабинета для этого поля использует 到期时间 (subscription.expiresAt,
admin.users.detail.expiresAt, promo.expiresIn), поэтому привёл к той же форме.
Русскую строку не трогал: «по истечению» здесь дательный падеж критерия, как
у соседей («по дате», «по балансу»), а не предложный «по истечении» из
временного оборота.
---
src/api/adminUsers.ts | 3 ++-
src/locales/zh.json | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts
index abd4863..56ace51 100644
--- a/src/api/adminUsers.ts
+++ b/src/api/adminUsers.ts
@@ -456,7 +456,8 @@ export const adminUsersApi = {
| 'traffic'
| 'last_activity'
| 'total_spent'
- | 'purchase_count';
+ | 'purchase_count'
+ | 'subscription_end_date';
} = {},
): Promise => {
const response = await apiClient.get('/cabinet/admin/users', { params });
diff --git a/src/locales/zh.json b/src/locales/zh.json
index d6b353e..87b3dce 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -2963,7 +2963,7 @@
"byBalance": "按余额",
"byActivity": "按活跃度",
"bySpent": "按消费",
- "byExpiry": "按到期"
+ "byExpiry": "按到期时间"
},
"pagination": {
"showing": "显示 {{from}}-{{to}},共 {{total}}"
From c1088325aabaf43de6f60653ff650c3cfb1beb2f Mon Sep 17 00:00:00 2001
From: Fringg
Date: Thu, 6 Aug 2026 02:16:02 +0300
Subject: [PATCH 6/7] =?UTF-8?q?fix(auth):=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83?=
=?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=20=D0=BF?=
=?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=C2=AB=D0=9D=D0=B0=D0=B7=D0=B0=D0=B4?=
=?UTF-8?q?=C2=BB=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B8=D1=82=D1=8C=20=D0=BB?=
=?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BB=D0=B8=20=D0=B4=D0=BE=20=D1=87=D0=B5?=
=?UTF-8?q?=D1=82=D1=8B=D1=80=D1=91=D1=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Дополнения к PR #539 при мерже.
Кнопка «Назад к входу через виджет» оставляла пустое место. Пока открыт
deep-link-экран, контейнер виджета размонтирован ранним return'ом, а эффект,
который вставляет скрипт Telegram, от этого не перезапускается: его
зависимости не меняются. Единственная зависимость, способная дрогнуть, —
handleScriptFailed через scriptLoaded, но на legacy-пути scriptLoaded не
выставляется никогда (только в OIDC-ветке). Так что при возврате контейнер
монтировался обратно уже пустым, и войти через виджет было нельзя до
перезагрузки страницы. Добавлен showDeepLinkUI в зависимости и ранний выход:
на входе в deep-link отрабатывает cleanup, на выходе скрипт вставляется заново.
Локали были только en и ru, а в кабинете их четыре. fallbackLng — 'ru',
поэтому персидские и китайские пользователи увидели бы русский текст на самом
первом экране. Именно этот класс регрессии описан в шапке locales.test.ts, и
поймать его тест не может: он сравнивает только en и ru. Добавлены fa и zh;
в zh термин «виджет» приведён к тому же 小部件, что уже используется в
telegramWidgetBlocked.
---
src/components/TelegramLoginButton.tsx | 17 +++++++++++++++--
src/locales/fa.json | 3 +++
src/locales/zh.json | 3 +++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx
index 05d79de..9cc0c59 100644
--- a/src/components/TelegramLoginButton.tsx
+++ b/src/components/TelegramLoginButton.tsx
@@ -172,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
useEffect(() => {
- if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
+ // showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер
+ // виджета размонтирован, а при возврате «Назад к виджету» сам по себе
+ // эффект не перезапустится — на legacy-пути scriptLoaded не меняется
+ // никогда, поэтому ни одна из остальных зависимостей не дрогнет, и
+ // пользователь получил бы пустое место вместо виджета.
+ if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
const container = containerRef.current;
while (container.firstChild) {
@@ -233,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
container.removeChild(container.firstChild);
}
};
- }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]);
+ }, [
+ showDeepLinkUI,
+ isOIDC,
+ botUsername,
+ widgetConfig,
+ loginWithTelegramWidget,
+ navigate,
+ handleScriptFailed,
+ ]);
// Deep link auth: request token and start polling with recursive setTimeout
const startDeepLinkAuth = useCallback(async () => {
diff --git a/src/locales/fa.json b/src/locales/fa.json
index fe8a782..f135511 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -199,6 +199,9 @@
"orOpenInApp": "یا ربات را در برنامه باز کنید",
"loginFailed": "ورود ناموفق",
"telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:",
+ "deepLinkIntro": "ورود را مستقیماً در ربات تأیید کنید — بدون نیاز به شماره تلفن:",
+ "loginWithBot": "ورود از طریق ربات",
+ "backToWidget": "بازگشت به ورود با ویجت",
"openBotToLogin": "باز کردن ربات برای ورود",
"waitingForConfirmation": "در انتظار تایید...",
"deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 87b3dce..754fb98 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -199,6 +199,9 @@
"orOpenInApp": "或在应用中打开机器人",
"loginFailed": "登录失败",
"telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录:",
+ "deepLinkIntro": "直接在机器人中确认登录 — 无需手机号:",
+ "loginWithBot": "通过机器人登录",
+ "backToWidget": "返回小部件登录",
"openBotToLogin": "打开机器人登录",
"waitingForConfirmation": "等待确认...",
"deepLinkExpired": "链接已过期,请重试。",
From 415a76f7cff4e0e06edb15b91a24467f0465e2e9 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Thu, 6 Aug 2026 02:25:02 +0300
Subject: [PATCH 7/7] =?UTF-8?q?fix(users):=20=D0=BF=D0=BE=D0=BA=D0=B0?=
=?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=D1=87?=
=?UTF-8?q?=D0=B8=D0=BD=D1=83=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=20?=
=?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?=
=?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Дополнение к PR #542 при мерже.
Обработчик глушил ошибку целиком и показывал общее «Ошибка». Между тем сервер
отвечает ровно теми словами, которые админу и нужны: открытый временный доступ
(409, «сначала заверши или восстанови grace»), активная платная без force (409),
подписки нет (404). Первый случай автор сам описал в PR как штатный сценарий —
и именно он выглядел как сломанная кнопка. Прокинул getApiErrorMessage, он для
этого в репозитории и заведён и уже используется в восьми других местах.
Логику force не трогал: она совпадает с сервером. Сериализуемое is_active
собирается в _build_subscription_info как `status == active AND end_date > now`,
то есть в точности серверное свойство Subscription.is_active, по которому и
стоит защита. Разъехаться значения могут только в безопасную сторону: подписка
успела истечь между загрузкой страницы и нажатием — тогда защиты нет и на
сервере.
---
src/pages/AdminUserDetail.tsx | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx
index 7902e06..09a1e18 100644
--- a/src/pages/AdminUserDetail.tsx
+++ b/src/pages/AdminUserDetail.tsx
@@ -28,6 +28,7 @@ import { ActivityTab } from '../components/admin/userDetail/ActivityTab';
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
import { InfoTab } from '../components/admin/userDetail/InfoTab';
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
+import { getApiErrorMessage } from '../utils/api-error';
import { toNumber } from '../utils/inputHelpers';
import { usePermissionStore } from '../store/permissions';
@@ -673,8 +674,12 @@ export default function AdminUserDetail() {
notify.success(t('admin.users.detail.subscription.deleted'), t('common.success'));
setSubscriptionDetailView(false);
await loadUser();
- } catch {
- notify.error(t('admin.users.userActions.error'), t('common.error'));
+ } catch (err) {
+ // Отказы тут осмысленные и действенные: открытый временный доступ
+ // (409, «сначала заверши или восстанови grace»), активная платная без
+ // force (409), подписки нет (404). Общее «Ошибка» оставило бы админа
+ // гадать, почему кнопка не сработала, — показываем текст сервера.
+ notify.error(getApiErrorMessage(err, t('admin.users.userActions.error')), t('common.error'));
} finally {
setActionLoading(false);
}