fix(ui): нормализовать detail из ответов API перед рендером — краш на 422

Что: все обработчики ошибок, которые клали сырой 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, но безопасное для любой формы ответа.
This commit is contained in:
kewldan
2026-07-12 17:59:56 +03:00
parent 20565b8f59
commit 9b972931cf
5 changed files with 32 additions and 32 deletions

View File

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

View File

@@ -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 }) => (
<PiCaretDown className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`} />
@@ -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')));
},
});

View File

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

View File

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

View File

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