mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge PR #477: нормализовать detail из ответов API перед рендером (краш на 422)
FastAPI/Pydantic на 422 кладёт в detail массив объектов {type,loc,msg,...};
компоненты рендерили его в JSX как есть → React падал в ErrorBoundary
(Objects are not valid as a React child), пользователь не видел причину.
Все такие обработчики (Login, Profile, ConnectedAccounts, admin AnalyticsTab
и MenuEditorTab) переведены на существующий getApiErrorMessage — строковый
detail возвращается как есть (ветки .includes работают), Pydantic-массив
разворачивается в строку field: msg; ..., любой формат безопасен для рендера.
Валидация на смердженном дереве: tsc, biome lint/format, build — чисто.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { brandingApi } from '../../api/branding';
|
import { brandingApi } from '../../api/branding';
|
||||||
|
import { getApiErrorMessage } from '../../utils/api-error';
|
||||||
import { CheckIcon, CloseIcon, PencilIcon } from './icons';
|
import { CheckIcon, CloseIcon, PencilIcon } from './icons';
|
||||||
|
|
||||||
export function AnalyticsTab() {
|
export function AnalyticsTab() {
|
||||||
@@ -31,8 +32,7 @@ export function AnalyticsTab() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
setError(getApiErrorMessage(err, t('common.error')));
|
||||||
setError(detail || t('common.error'));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { Toggle } from './Toggle';
|
import { Toggle } from './Toggle';
|
||||||
import { useNotify } from '../../platform/hooks/useNotify';
|
import { useNotify } from '../../platform/hooks/useNotify';
|
||||||
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
|
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
|
||||||
|
import { getApiErrorMessage } from '../../utils/api-error';
|
||||||
|
|
||||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||||
<PiCaretDown className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
<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);
|
queryClient.setQueryData(['menu-layout'], data);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } } };
|
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||||
const detail = error.response?.data?.detail;
|
|
||||||
notify.error(detail || t('common.error'));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/primitives/Button';
|
|||||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||||
import ProviderIcon from '../components/ProviderIcon';
|
import ProviderIcon from '../components/ProviderIcon';
|
||||||
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth';
|
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 { getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||||
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
||||||
import { useAuthStore } from '../store/auth';
|
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 —
|
// Note: auth user lives in the zustand store, not in React Query —
|
||||||
// the explicit setUser above IS the refresh. No ['user'] query exists.
|
// the explicit setUser above IS the refresh. No ['user'] query exists.
|
||||||
},
|
},
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
onError: (err: unknown) => {
|
||||||
const detail = err.response?.data?.detail;
|
const detail = getApiErrorMessage(err, '');
|
||||||
// The email belongs to another account and merging it requires proving
|
// The email belongs to another account and merging it requires proving
|
||||||
// ownership: the backend asks for THAT account's password (account-takeover
|
// 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.
|
// 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'));
|
setEmailError(t('profile.emailMergePasswordRequired'));
|
||||||
} else if (detail?.includes('already registered')) {
|
} else if (detail.includes('already registered')) {
|
||||||
setEmailError(t('profile.emailAlreadyRegistered'));
|
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'));
|
setEmailError(t('profile.alreadyHaveEmail'));
|
||||||
} else {
|
} else {
|
||||||
setEmailError(detail || t('common.error'));
|
setEmailError(detail || t('common.error'));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type EmailAuthEnabled,
|
type EmailAuthEnabled,
|
||||||
} from '../api/branding';
|
} from '../api/branding';
|
||||||
import { getAndClearReturnUrl, tokenStorage } from '../utils/token';
|
import { getAndClearReturnUrl, tokenStorage } from '../utils/token';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
|
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||||
import { closeMiniApp } from '@telegram-apps/sdk-react';
|
import { closeMiniApp } from '@telegram-apps/sdk-react';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
@@ -190,9 +191,9 @@ export default function Login() {
|
|||||||
navigate(getReturnUrl(), { replace: true });
|
navigate(getReturnUrl(), { replace: true });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} 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 status = error.response?.status;
|
||||||
const detail = error.response?.data?.detail;
|
const detail = getApiErrorMessage(err, '');
|
||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
||||||
|
|
||||||
@@ -268,14 +269,14 @@ export default function Login() {
|
|||||||
setRegisteredEmail(result.email);
|
setRegisteredEmail(result.email);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} 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 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'));
|
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
||||||
} else if (status === 401 || status === 403) {
|
} 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'));
|
setError(t('auth.emailNotVerified', 'Please verify your email first'));
|
||||||
} else {
|
} else {
|
||||||
setError(t('auth.invalidCredentials', 'Invalid email or password'));
|
setError(t('auth.invalidCredentials', 'Invalid email or password'));
|
||||||
@@ -304,9 +305,7 @@ export default function Login() {
|
|||||||
await authApi.forgotPassword(forgotPasswordEmail.trim());
|
await authApi.forgotPassword(forgotPasswordEmail.trim());
|
||||||
setForgotPasswordSent(true);
|
setForgotPasswordSent(true);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
setForgotPasswordError(getApiErrorMessage(err, t('common.error')));
|
||||||
const detail = error.response?.data?.detail;
|
|
||||||
setForgotPasswordError(detail || t('common.error'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setForgotPasswordLoading(false);
|
setForgotPasswordLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { useAuthStore } from '../store/auth';
|
|||||||
import { displayName } from '../utils/displayName';
|
import { displayName } from '../utils/displayName';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { isValidEmail } from '../utils/validation';
|
import { isValidEmail } from '../utils/validation';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
import {
|
import {
|
||||||
notificationsApi,
|
notificationsApi,
|
||||||
NotificationSettings,
|
type NotificationSettings,
|
||||||
NotificationSettingsUpdate,
|
type NotificationSettingsUpdate,
|
||||||
} from '../api/notifications';
|
} from '../api/notifications';
|
||||||
import { referralApi } from '../api/referral';
|
import { referralApi } from '../api/referral';
|
||||||
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
||||||
@@ -112,8 +113,8 @@ export default function Profile() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
||||||
},
|
},
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
onError: (err: unknown) => {
|
||||||
setError(err.response?.data?.detail || t('common.error'));
|
setError(getApiErrorMessage(err, t('common.error')));
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -133,13 +134,13 @@ export default function Profile() {
|
|||||||
setResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
setResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
onError: (err: unknown) => {
|
||||||
const detail = err.response?.data?.detail;
|
const detail = getApiErrorMessage(err, '');
|
||||||
if (detail?.includes('already registered') || detail?.includes('already in use')) {
|
if (detail.includes('already registered') || detail.includes('already in use')) {
|
||||||
setChangeError(t('profile.changeEmail.emailAlreadyUsed'));
|
setChangeError(t('profile.changeEmail.emailAlreadyUsed'));
|
||||||
} else if (detail?.includes('same as current')) {
|
} else if (detail.includes('same as current')) {
|
||||||
setChangeError(t('profile.changeEmail.sameEmail'));
|
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'));
|
setChangeError(t('profile.changeEmail.tooManyRequests'));
|
||||||
} else {
|
} else {
|
||||||
setChangeError(detail || t('common.error'));
|
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 —
|
// Note: auth user lives in the zustand store, not in React Query —
|
||||||
// the explicit setUser above IS the refresh. No ['user'] query exists.
|
// the explicit setUser above IS the refresh. No ['user'] query exists.
|
||||||
},
|
},
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
onError: (err: unknown) => {
|
||||||
const detail = err.response?.data?.detail;
|
const detail = getApiErrorMessage(err, '');
|
||||||
if (detail?.includes('invalid') || detail?.includes('wrong')) {
|
if (detail.includes('invalid') || detail.includes('wrong')) {
|
||||||
setChangeError(t('profile.changeEmail.invalidCode'));
|
setChangeError(t('profile.changeEmail.invalidCode'));
|
||||||
} else if (detail?.includes('expired')) {
|
} else if (detail.includes('expired')) {
|
||||||
setChangeError(t('profile.changeEmail.codeExpired'));
|
setChangeError(t('profile.changeEmail.codeExpired'));
|
||||||
} else {
|
} else {
|
||||||
setChangeError(detail || t('common.error'));
|
setChangeError(detail || t('common.error'));
|
||||||
|
|||||||
Reference in New Issue
Block a user