mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
|
||||
86
src/components/blocking/AccountDeletedScreen.tsx
Normal file
86
src/components/blocking/AccountDeletedScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4203,6 +4203,13 @@
|
||||
"defaultMessage": "حساب شما مسدود شده است.",
|
||||
"reason": "دلیل",
|
||||
"contactSupport": "اگر فکر میکنید این اشتباه است، با پشتیبانی تماس بگیرید."
|
||||
},
|
||||
"accountDeleted": {
|
||||
"title": "حساب غیرفعال شد",
|
||||
"description": "حساب شما به دلیل عدم فعالیت طولانی غیرفعال شده است. برای بازیابی دسترسی، ربات را در تلگرام باز کرده و دستور /start را ارسال کنید.",
|
||||
"openBot": "باز کردن ربات",
|
||||
"retry": "من /start را زدم، دوباره امتحان کن",
|
||||
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
|
||||
@@ -5218,6 +5218,13 @@
|
||||
"defaultMessage": "Ваш аккаунт заблокирован.",
|
||||
"reason": "Причина",
|
||||
"contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
},
|
||||
"accountDeleted": {
|
||||
"title": "Аккаунт деактивирован",
|
||||
"description": "Ваш аккаунт был деактивирован за длительную неактивность. Чтобы восстановить доступ, откройте бота в Telegram и отправьте команду /start.",
|
||||
"openBot": "Открыть бота",
|
||||
"retry": "Я нажал /start, попробовать снова",
|
||||
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
|
||||
@@ -4084,6 +4084,13 @@
|
||||
"defaultMessage": "您的帐户已被封锁。",
|
||||
"reason": "原因",
|
||||
"contactSupport": "如果您认为这是错误,请联系客服。"
|
||||
},
|
||||
"accountDeleted": {
|
||||
"title": "账户已停用",
|
||||
"description": "由于长期未活动,您的账户已被停用。请在 Telegram 中打开机器人并发送 /start 以恢复访问。",
|
||||
"openBot": "打开机器人",
|
||||
"retry": "我已按 /start,重试",
|
||||
"hint": "运行 /start 后,返回此处并点击「重试」。"
|
||||
}
|
||||
},
|
||||
"banSystem": {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user