fix(auth): i18n и react-hook-form на странице логина
Добавлены i18next, схема zod и react-hook-form для формы входа. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Generated
+2696
-31
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,17 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
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';
|
import App from './App';
|
||||||
|
import './i18n';
|
||||||
dayjs.locale('ru');
|
import LocaleProvider from './components/LocaleProvider';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ConfigProvider locale={locale}>
|
<LocaleProvider>
|
||||||
<App />
|
<App />
|
||||||
</ConfigProvider>
|
</LocaleProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +1,67 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Form, Input, Button, Card, message } from 'antd';
|
import { Form, Input, Button, Card, message } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { loginSchema, LoginFormValues } from '../../schemas/loginSchema';
|
||||||
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 'Ошибка входа';
|
|
||||||
};
|
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const login = useAuthStore((s) => s.login);
|
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 {
|
try {
|
||||||
await login(values.email, values.password);
|
await login(values.email, values.password);
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
<Card title="Вход" style={{ width: 400 }}>
|
<Card title={t('login.title')} style={{ width: 400 }}>
|
||||||
<Form form={form} onFinish={onFinish} layout="vertical">
|
<Form layout="vertical" onFinish={handleSubmit(onFinish)}>
|
||||||
<Form.Item name="email" label="Email" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input autoComplete="username" />
|
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>
|
||||||
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input.Password autoComplete="current-password" />
|
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>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<Button type="primary" htmlType="submit" block loading={isSubmitting}>
|
||||||
Войти
|
{t('login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user