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,
}),
}));