feat(blocking): account-deleted recovery screen with bot deep-link

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).
This commit is contained in:
Fringg
2026-05-16 05:16:24 +03:00
parent 47f0359ed6
commit 16b47119c9
9 changed files with 181 additions and 1 deletions

View File

@@ -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 <BlacklistedScreen />;
}
if (blockingType === 'account_deleted') {
return <AccountDeletedScreen />;
}
return null;
}

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,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 (
<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';

View File

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

View File

@@ -4203,6 +4203,13 @@
"defaultMessage": "حساب شما مسدود شده است.",
"reason": "دلیل",
"contactSupport": "اگر فکر می‌کنید این اشتباه است، با پشتیبانی تماس بگیرید."
},
"accountDeleted": {
"title": "حساب غیرفعال شد",
"description": "حساب شما به دلیل عدم فعالیت طولانی غیرفعال شده است. برای بازیابی دسترسی، ربات را در تلگرام باز کرده و دستور /start را ارسال کنید.",
"openBot": "باز کردن ربات",
"retry": "من /start را زدم، دوباره امتحان کن",
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
}
},
"merge": {

View File

@@ -5218,6 +5218,13 @@
"defaultMessage": "Ваш аккаунт заблокирован.",
"reason": "Причина",
"contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку."
},
"accountDeleted": {
"title": "Аккаунт деактивирован",
"description": "Ваш аккаунт был деактивирован за длительную неактивность. Чтобы восстановить доступ, откройте бота в Telegram и отправьте команду /start.",
"openBot": "Открыть бота",
"retry": "Я нажал /start, попробовать снова",
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
}
},
"merge": {

View File

@@ -4084,6 +4084,13 @@
"defaultMessage": "您的帐户已被封锁。",
"reason": "原因",
"contactSupport": "如果您认为这是错误,请联系客服。"
},
"accountDeleted": {
"title": "账户已停用",
"description": "由于长期未活动,您的账户已被停用。请在 Telegram 中打开机器人并发送 /start 以恢复访问。",
"openBot": "打开机器人",
"retry": "我已按 /start重试",
"hint": "运行 /start 后,返回此处并点击「重试」。"
}
},
"banSystem": {

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