From 9b972931cf8ea85f84538bf0b421df2730c4db66 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:59:56 +0300 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20detail=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=20API=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=20=E2=80=94=20=D0=BA=D1=80=D0=B0=D1=88=20?= =?UTF-8?q?=D0=BD=D0=B0=20422?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: все обработчики ошибок, которые клали сырой response.data.detail в состояние и затем в JSX, переведены на существующий хелпер getApiErrorMessage (src/utils/api-error.ts): - Login.tsx — Telegram-auth, вход/регистрация по email, восстановление пароля - Profile.tsx — смена email (запрос и подтверждение кода), повторная отправка письма верификации - ConnectedAccounts.tsx — привязка email к аккаунту - admin/AnalyticsTab.tsx, admin/MenuEditorTab.tsx — сохранение настроек Почему: на невалидный по схеме запрос FastAPI/Pydantic отвечает 422, где detail — не строка, а массив объектов {type, loc, msg, input, ctx}. Компоненты рендерили его как есть, React падал с «Objects are not valid as a React child», и вся страница (включая логин) уходила в ErrorBoundary «Something went wrong» — пользователь не видел причину отказа вообще. Зачем: getApiErrorMessage сводит Pydantic-массив к строке «field: message; ...», поэтому вместо краша форма показывает настоящий текст ошибки валидации. Ветвления по detail.includes(...) сохранены и работают по нормализованной строке — поведение прежнее для строковых detail, но безопасное для любой формы ответа. --- src/components/admin/AnalyticsTab.tsx | 4 ++-- src/components/admin/MenuEditorTab.tsx | 5 ++--- src/pages/ConnectedAccounts.tsx | 11 ++++++----- src/pages/Login.tsx | 17 ++++++++-------- src/pages/Profile.tsx | 27 +++++++++++++------------- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/components/admin/AnalyticsTab.tsx b/src/components/admin/AnalyticsTab.tsx index b0278ac..3f11ff0 100644 --- a/src/components/admin/AnalyticsTab.tsx +++ b/src/components/admin/AnalyticsTab.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { brandingApi } from '../../api/branding'; +import { getApiErrorMessage } from '../../utils/api-error'; import { CheckIcon, CloseIcon, PencilIcon } from './icons'; export function AnalyticsTab() { @@ -31,8 +32,7 @@ export function AnalyticsTab() { setError(null); }, onError: (err: unknown) => { - const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; - setError(detail || t('common.error')); + setError(getApiErrorMessage(err, t('common.error'))); }, }); diff --git a/src/components/admin/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx index 56dbf11..04a5fd6 100644 --- a/src/components/admin/MenuEditorTab.tsx +++ b/src/components/admin/MenuEditorTab.tsx @@ -39,6 +39,7 @@ import { import { Toggle } from './Toggle'; import { useNotify } from '../../platform/hooks/useNotify'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; +import { getApiErrorMessage } from '../../utils/api-error'; const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( @@ -511,9 +512,7 @@ export function MenuEditorTab() { queryClient.setQueryData(['menu-layout'], data); }, onError: (err: unknown) => { - const error = err as { response?: { data?: { detail?: string } } }; - const detail = error.response?.data?.detail; - notify.error(detail || t('common.error')); + notify.error(getApiErrorMessage(err, t('common.error'))); }, }); diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 9270f87..b002e95 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth'; +import { getApiErrorMessage } from '../utils/api-error'; import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import { useAuthStore } from '../store/auth'; @@ -422,16 +423,16 @@ export default function ConnectedAccounts() { // Note: auth user lives in the zustand store, not in React Query — // the explicit setUser above IS the refresh. No ['user'] query exists. }, - onError: (err: { response?: { data?: { detail?: string } } }) => { - const detail = err.response?.data?.detail; + onError: (err: unknown) => { + const detail = getApiErrorMessage(err, ''); // The email belongs to another account and merging it requires proving // ownership: the backend asks for THAT account's password (account-takeover // fix). Guide the user to enter it rather than showing a dead-end error. - if (detail?.includes('merge')) { + if (detail.includes('merge')) { setEmailError(t('profile.emailMergePasswordRequired')); - } else if (detail?.includes('already registered')) { + } else if (detail.includes('already registered')) { setEmailError(t('profile.emailAlreadyRegistered')); - } else if (detail?.includes('already have a verified email')) { + } else if (detail.includes('already have a verified email')) { setEmailError(t('profile.alreadyHaveEmail')); } else { setEmailError(detail || t('common.error')); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 7becbdc..b3afb12 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -16,6 +16,7 @@ import { type EmailAuthEnabled, } from '../api/branding'; import { getAndClearReturnUrl, tokenStorage } from '../utils/token'; +import { getApiErrorMessage } from '../utils/api-error'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK'; import { closeMiniApp } from '@telegram-apps/sdk-react'; import LanguageSwitcher from '../components/LanguageSwitcher'; @@ -190,9 +191,9 @@ export default function Login() { navigate(getReturnUrl(), { replace: true }); return; } catch (err) { - const error = err as { response?: { status?: number; data?: { detail?: string } } }; + const error = err as { response?: { status?: number } }; const status = error.response?.status; - const detail = error.response?.data?.detail; + const detail = getApiErrorMessage(err, ''); if (import.meta.env.DEV) console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail); @@ -268,14 +269,14 @@ export default function Login() { setRegisteredEmail(result.email); } } catch (err: unknown) { - const error = err as { response?: { status?: number; data?: { detail?: string } } }; + const error = err as { response?: { status?: number } }; const status = error.response?.status; - const detail = error.response?.data?.detail; + const detail = getApiErrorMessage(err, ''); - if (status === 400 && detail?.includes('already registered')) { + if (status === 400 && detail.includes('already registered')) { setError(t('auth.emailAlreadyRegistered', 'This email is already registered')); } else if (status === 401 || status === 403) { - if (detail?.includes('verify your email')) { + if (detail.includes('verify your email')) { setError(t('auth.emailNotVerified', 'Please verify your email first')); } else { setError(t('auth.invalidCredentials', 'Invalid email or password')); @@ -304,9 +305,7 @@ export default function Login() { await authApi.forgotPassword(forgotPasswordEmail.trim()); setForgotPasswordSent(true); } catch (err: unknown) { - const error = err as { response?: { status?: number; data?: { detail?: string } } }; - const detail = error.response?.data?.detail; - setForgotPasswordError(detail || t('common.error')); + setForgotPasswordError(getApiErrorMessage(err, t('common.error'))); } finally { setForgotPasswordLoading(false); } diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index fe82370..e1bd4b3 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -9,10 +9,11 @@ import { useAuthStore } from '../store/auth'; import { displayName } from '../utils/displayName'; import { authApi } from '../api/auth'; import { isValidEmail } from '../utils/validation'; +import { getApiErrorMessage } from '../utils/api-error'; import { notificationsApi, - NotificationSettings, - NotificationSettingsUpdate, + type NotificationSettings, + type NotificationSettingsUpdate, } from '../api/notifications'; import { referralApi } from '../api/referral'; import { brandingApi, type EmailAuthEnabled } from '../api/branding'; @@ -112,8 +113,8 @@ export default function Profile() { setError(null); setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC); }, - onError: (err: { response?: { data?: { detail?: string } } }) => { - setError(err.response?.data?.detail || t('common.error')); + onError: (err: unknown) => { + setError(getApiErrorMessage(err, t('common.error'))); setSuccess(null); }, }); @@ -133,13 +134,13 @@ export default function Profile() { setResendCooldown(UI.RESEND_COOLDOWN_SEC); } }, - onError: (err: { response?: { data?: { detail?: string } } }) => { - const detail = err.response?.data?.detail; - if (detail?.includes('already registered') || detail?.includes('already in use')) { + onError: (err: unknown) => { + const detail = getApiErrorMessage(err, ''); + if (detail.includes('already registered') || detail.includes('already in use')) { setChangeError(t('profile.changeEmail.emailAlreadyUsed')); - } else if (detail?.includes('same as current')) { + } else if (detail.includes('same as current')) { setChangeError(t('profile.changeEmail.sameEmail')); - } else if (detail?.includes('rate limit') || detail?.includes('too many')) { + } else if (detail.includes('rate limit') || detail.includes('too many')) { setChangeError(t('profile.changeEmail.tooManyRequests')); } else { setChangeError(detail || t('common.error')); @@ -157,11 +158,11 @@ export default function Profile() { // Note: auth user lives in the zustand store, not in React Query — // the explicit setUser above IS the refresh. No ['user'] query exists. }, - onError: (err: { response?: { data?: { detail?: string } } }) => { - const detail = err.response?.data?.detail; - if (detail?.includes('invalid') || detail?.includes('wrong')) { + onError: (err: unknown) => { + const detail = getApiErrorMessage(err, ''); + if (detail.includes('invalid') || detail.includes('wrong')) { setChangeError(t('profile.changeEmail.invalidCode')); - } else if (detail?.includes('expired')) { + } else if (detail.includes('expired')) { setChangeError(t('profile.changeEmail.codeExpired')); } else { setChangeError(detail || t('common.error'));