fix(auth): i18n и react-hook-form на странице логина

Добавлены i18next, схема zod и react-hook-form для формы входа.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 17:54:00 +03:00
parent 755d22cf48
commit fd825ddfd7
8 changed files with 2865 additions and 66 deletions
+2696 -31
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import React, { useEffect } from 'react';
import { ConfigProvider } from 'antd';
import ruRU from 'antd/locale/ru_RU';
import enUS from 'antd/locale/en_US';
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import 'dayjs/locale/en';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const antdLocales = { ru: ruRU, en: enUS } as const;
const LocaleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { i18n } = useTranslation();
const language = useAuthStore((s) => s.user?.language);
const locale = language === 'en' ? 'en' : 'ru';
useEffect(() => {
void i18n.changeLanguage(locale);
dayjs.locale(locale);
}, [i18n, locale]);
return (
<ConfigProvider locale={antdLocales[locale]}>
{children}
</ConfigProvider>
);
};
export default LocaleProvider;
+16
View File
@@ -0,0 +1,16 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import ru from '../locales/ru.json';
import en from '../locales/en.json';
void i18n.use(initReactI18next).init({
resources: {
ru: { translation: ru },
en: { translation: en },
},
lng: 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
});
export default i18n;
+34
View File
@@ -0,0 +1,34 @@
{
"menu": {
"dashboard": "Dashboard",
"users": "Users",
"calendars": "Calendars",
"events": "Events",
"reports": "Reports",
"reviews": "Reviews",
"bannedWords": "Banned words",
"tickets": "Tickets",
"subscriptions": "Subscriptions",
"admins": "Admins",
"audit": "Audit",
"monitoring": "Monitoring"
},
"profile": {
"title": "My profile",
"edit": "Edit",
"saved": "Profile updated"
},
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"invalid_credentials": "Invalid email or password",
"insufficient_permissions": "Insufficient permissions",
"error": "Sign-in error"
},
"ws": {
"newReport": "New report",
"newTicket": "New ticket"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"menu": {
"dashboard": "Дашборд",
"users": "Пользователи",
"calendars": "Календари",
"events": "События",
"reports": "Жалобы",
"reviews": "Отзывы",
"bannedWords": "Бан-слова",
"tickets": "Тикеты",
"subscriptions": "Подписки",
"admins": "Администраторы",
"audit": "Аудит",
"monitoring": "Мониторинг"
},
"profile": {
"title": "Мой профиль",
"edit": "Редактировать",
"saved": "Профиль обновлён"
},
"login": {
"title": "Вход",
"email": "Email",
"password": "Пароль",
"submit": "Войти",
"invalid_credentials": "Неверный email или пароль",
"insufficient_permissions": "Недостаточно прав для входа",
"error": "Ошибка входа"
},
"ws": {
"newReport": "Новая жалоба",
"newTicket": "Новый тикет"
}
}
+5 -9
View File
@@ -1,17 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import locale from 'antd/locale/ru_RU';
import { ConfigProvider } from 'antd';
import App from './App';
dayjs.locale('ru');
import './i18n';
import LocaleProvider from './components/LocaleProvider';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={locale}>
<LocaleProvider>
<App />
</ConfigProvider>
</LocaleProvider>
</React.StrictMode>
);
);
+42 -26
View File
@@ -1,51 +1,67 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
const LOGIN_ERROR_MESSAGES: Record<string, string> = {
invalid_credentials: 'Неверный email или пароль',
insufficient_permissions: 'Недостаточно прав для входа',
};
const getLoginErrorMessage = (error: unknown): string => {
if (isAxiosError(error)) {
const apiError = error.response?.data?.error;
if (typeof apiError === 'string') {
return LOGIN_ERROR_MESSAGES[apiError] ?? apiError;
}
}
return 'Ошибка входа';
};
import { loginSchema, LoginFormValues } from '../../schemas/loginSchema';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const [form] = Form.useForm();
const { t } = useTranslation();
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
const onFinish = async (values: { email: string; password: string }) => {
const onFinish = async (values: LoginFormValues) => {
try {
await login(values.email, values.password);
navigate('/dashboard');
} catch (error) {
message.error(getLoginErrorMessage(error));
if (isAxiosError(error)) {
const apiError = error.response?.data?.error;
if (typeof apiError === 'string') {
message.error(t(`login.${apiError}`, { defaultValue: apiError }));
return;
}
}
message.error(t('login.error'));
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Card title="Вход" style={{ width: 400 }}>
<Form form={form} onFinish={onFinish} layout="vertical">
<Form.Item name="email" label="Email" rules={[{ required: true }]}>
<Input autoComplete="username" />
<Card title={t('login.title')} style={{ width: 400 }}>
<Form layout="vertical" onFinish={handleSubmit(onFinish)}>
<Form.Item
label={t('login.email')}
validateStatus={errors.email ? 'error' : ''}
help={errors.email?.message}
>
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} autoComplete="username" />}
/>
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password autoComplete="current-password" />
<Form.Item
label={t('login.password')}
validateStatus={errors.password ? 'error' : ''}
help={errors.password?.message}
>
<Controller
name="password"
control={control}
render={({ field }) => <Input.Password {...field} autoComplete="current-password" />}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
Войти
<Button type="primary" htmlType="submit" block loading={isSubmitting}>
{t('login.submit')}
</Button>
</Form.Item>
</Form>
+8
View File
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().min(1, 'Email обязателен').email('Некорректный email'),
password: z.string().min(1, 'Пароль обязателен'),
});
export type LoginFormValues = z.infer<typeof loginSchema>;