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

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