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

@@ -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 || '';