From 16b47119c9a5da682f43727d3b747cbd5f06593d Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 05:16:24 +0300 Subject: [PATCH] feat(blocking): account-deleted recovery screen with bot deep-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with backend feat(deleted-users) — when the cabinet returns 403 {detail: {code: 'account_deleted', message, bot_username, telegram_deep_link}} the SPA shows a dedicated screen instead of the generic auth-error toast. * Adds AccountDeletedScreen with localized title/description, an Open-bot button using the backend-provided telegram_deep_link, and a Retry button that clears the block + reloads (so the next request observes the now-revived row). * useBlockingStore gains accountDeleted slice mirroring the existing maintenance/channel-subscription/blacklisted slots — consistent with the precedent for cross-cutting 403 codes. * apiClient: AccountDeletedError type + isAccountDeletedError guard + response interceptor routes the 403 into the blocking store. * i18n: ru/en/zh/fa parity for title, description, openBot, retry, hint. No behavior change for ACTIVE users. BLOCKED users still get the generic 403 (the backend keeps the message opaque for admin actions). --- src/App.tsx | 5 ++ src/api/client.ts | 30 +++++++ .../blocking/AccountDeletedScreen.tsx | 86 +++++++++++++++++++ src/components/blocking/index.ts | 1 + src/locales/en.json | 7 ++ src/locales/fa.json | 7 ++ src/locales/ru.json | 7 ++ src/locales/zh.json | 7 ++ src/store/blocking.ts | 32 ++++++- 9 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/components/blocking/AccountDeletedScreen.tsx diff --git a/src/App.tsx b/src/App.tsx index fce6599..6fbd3ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { MaintenanceScreen, ChannelSubscriptionScreen, BlacklistedScreen, + AccountDeletedScreen, } from './components/blocking'; import { ErrorBoundary } from './components/ErrorBoundary'; import { PermissionRoute } from '@/components/auth/PermissionRoute'; @@ -219,6 +220,10 @@ function BlockingOverlay() { return ; } + if (blockingType === 'account_deleted') { + return ; + } + return null; } 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/components/blocking/AccountDeletedScreen.tsx b/src/components/blocking/AccountDeletedScreen.tsx new file mode 100644 index 0000000..8cd04a4 --- /dev/null +++ b/src/components/blocking/AccountDeletedScreen.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next'; +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 info = useBlockingStore((state) => state.accountDeletedInfo); + + const deepLink = info?.telegram_deep_link?.trim() || null; + const handleOpenBot = () => { + if (deepLink) { + window.open(deepLink, '_blank', 'noopener,noreferrer'); + } + }; + + 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/locales/en.json b/src/locales/en.json index f50417f..1627841 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4666,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 d67089d..f55f2f2 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -4203,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 f6c5cac..da3ccc8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -5218,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 5b5860f..728d600 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -4084,6 +4084,13 @@ "defaultMessage": "您的帐户已被封锁。", "reason": "原因", "contactSupport": "如果您认为这是错误,请联系客服。" + }, + "accountDeleted": { + "title": "账户已停用", + "description": "由于长期未活动,您的账户已被停用。请在 Telegram 中打开机器人并发送 /start 以恢复访问。", + "openBot": "打开机器人", + "retry": "我已按 /start,重试", + "hint": "运行 /start 后,返回此处并点击「重试」。" } }, "banSystem": { 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, }), }));