From 68bbbf8895d6196f67218669dc6e8f546fe2b64f Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:36:19 +0500 Subject: [PATCH 01/10] refactor(auth): extract AuthPasswordForm and AuthCodeForm to features - Create auth-password feature with login/register form - Create auth-code feature with email+code verification form - Extract getApiErrorMessage to shared lib - Simplify AuthPage to pure UI composer with tabs - Update tests for new component structure - All 40 tests passing --- client/src/features/auth-code/index.ts | 1 + .../auth-code/ui/AuthCodeForm.test.tsx | 67 +++ .../features/auth-code/ui/AuthCodeForm.tsx | 100 +++++ client/src/features/auth-password/index.ts | 1 + .../ui/AuthPasswordForm.test.tsx | 74 ++++ .../auth-password/ui/AuthPasswordForm.tsx | 189 +++++++++ .../pages/auth/__tests__/AuthPage.test.tsx | 12 +- client/src/pages/auth/ui/AuthPage.tsx | 388 +----------------- .../src/shared/lib/get-api-error-message.ts | 8 + 9 files changed, 461 insertions(+), 379 deletions(-) create mode 100644 client/src/features/auth-code/index.ts create mode 100644 client/src/features/auth-code/ui/AuthCodeForm.test.tsx create mode 100644 client/src/features/auth-code/ui/AuthCodeForm.tsx create mode 100644 client/src/features/auth-password/index.ts create mode 100644 client/src/features/auth-password/ui/AuthPasswordForm.test.tsx create mode 100644 client/src/features/auth-password/ui/AuthPasswordForm.tsx create mode 100644 client/src/shared/lib/get-api-error-message.ts diff --git a/client/src/features/auth-code/index.ts b/client/src/features/auth-code/index.ts new file mode 100644 index 0000000..f5f22a4 --- /dev/null +++ b/client/src/features/auth-code/index.ts @@ -0,0 +1 @@ +export { AuthCodeForm } from './ui/AuthCodeForm' diff --git a/client/src/features/auth-code/ui/AuthCodeForm.test.tsx b/client/src/features/auth-code/ui/AuthCodeForm.test.tsx new file mode 100644 index 0000000..198ca02 --- /dev/null +++ b/client/src/features/auth-code/ui/AuthCodeForm.test.tsx @@ -0,0 +1,67 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { AuthCodeForm } from '../ui/AuthCodeForm' + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() })) + +function renderForm() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const onSuccess = vi.fn() + return render( + + + + + , + ) +} + +describe('AuthCodeForm', () => { + it('renders email field, code field, and buttons', () => { + renderForm() + expect(screen.getByLabelText(/Email/i)).toBeTruthy() + expect(screen.getByLabelText(/Код/i)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Отправить код' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy() + }) + + it('disables send button when email is empty', () => { + renderForm() + expect(screen.getByRole('button', { name: 'Отправить код' })).toBeDisabled() + }) + + it('disables login button when code.length !== 6', () => { + renderForm() + fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } }) + fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123' } }) + expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled() + }) + + it('enables login button when code is 6 digits', async () => { + renderForm() + fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } }) + fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled() + }) + }) + + it('calls onSuccess after successful verify', async () => { + const { apiClient } = await import('@/shared/api/client') + const { tokenSet } = await import('@/shared/model/auth') + vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never) + renderForm() + + fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } }) + fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: 'Войти' })) + + expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled() + await waitFor(() => { + expect(tokenSet).toHaveBeenCalledWith('test-token') + }) + }) +}) diff --git a/client/src/features/auth-code/ui/AuthCodeForm.tsx b/client/src/features/auth-code/ui/AuthCodeForm.tsx new file mode 100644 index 0000000..3a53843 --- /dev/null +++ b/client/src/features/auth-code/ui/AuthCodeForm.tsx @@ -0,0 +1,100 @@ +import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import { useMutation } from '@tanstack/react-query' +import { Mail } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { apiClient } from '@/shared/api/client' +import { getApiErrorMessage } from '@/shared/lib/get-api-error-message' +import { tokenSet } from '@/shared/model/auth' + +type AuthResponse = { + token: string + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarStyle?: string | null + } +} + +type FormValues = { + email: string + code: string +} + +type Props = { + onSuccess: () => void +} + +export function AuthCodeForm({ onSuccess }: Props) { + const { register, watch } = useForm({ + defaultValues: { email: '', code: '' }, + mode: 'onChange', + }) + + const email = watch('email') + const code = watch('code') + + const requestCodeMutation = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/request-code', { email }) + }, + }) + + const verifyCodeMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/verify-code', { email, code }) + tokenSet(data.token) + }, + onSuccess, + }) + + return ( + + + + + ), + }, + }} + /> + + + + + + + {(requestCodeMutation.error || verifyCodeMutation.error) && ( + + )} + + ) +} diff --git a/client/src/features/auth-password/index.ts b/client/src/features/auth-password/index.ts new file mode 100644 index 0000000..1cd0df0 --- /dev/null +++ b/client/src/features/auth-password/index.ts @@ -0,0 +1 @@ +export { AuthPasswordForm } from './ui/AuthPasswordForm' diff --git a/client/src/features/auth-password/ui/AuthPasswordForm.test.tsx b/client/src/features/auth-password/ui/AuthPasswordForm.test.tsx new file mode 100644 index 0000000..33a6c3e --- /dev/null +++ b/client/src/features/auth-password/ui/AuthPasswordForm.test.tsx @@ -0,0 +1,74 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { AuthPasswordForm } from '../ui/AuthPasswordForm' + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() })) + +function renderForm(isRegister: boolean) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const onSuccess = vi.fn() + return render( + + + + + , + ) +} + +describe('AuthPasswordForm', () => { + it('renders login button when isRegister=false', () => { + renderForm(false) + expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy() + expect(screen.getByText('Вход')).toBeTruthy() + }) + + it('renders register button and passwordConfirm when isRegister=true', () => { + renderForm(true) + expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeTruthy() + expect(screen.getByLabelText(/Подтверждение пароля/i)).toBeTruthy() + }) + + it('disables button when password < 8 chars', async () => { + const { apiClient } = await import('@/shared/api/client') + vi.mocked(apiClient.post).mockResolvedValue({} as never) + renderForm(true) + + fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } }) + fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: '123' } }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeDisabled() + }) + }) + + it('shows error when passwords do not match', async () => { + renderForm(true) + + fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } }) + fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } }) + fireEvent.change(screen.getByLabelText(/Подтверждение пароля/i), { target: { value: 'different' } }) + + await waitFor(() => { + expect(screen.getByText('Пароли не совпадают')).toBeTruthy() + }) + }) + + it('calls onSuccess after successful login', async () => { + const { apiClient } = await import('@/shared/api/client') + const { tokenSet } = await import('@/shared/model/auth') + vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never) + renderForm(false) + + fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } }) + fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } }) + fireEvent.click(screen.getByRole('button', { name: 'Войти' })) + + await waitFor(() => { + expect(tokenSet).toHaveBeenCalledWith('test-token') + }) + }) +}) diff --git a/client/src/features/auth-password/ui/AuthPasswordForm.tsx b/client/src/features/auth-password/ui/AuthPasswordForm.tsx new file mode 100644 index 0000000..4f3d311 --- /dev/null +++ b/client/src/features/auth-password/ui/AuthPasswordForm.tsx @@ -0,0 +1,189 @@ +import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import { useMutation } from '@tanstack/react-query' +import { Lock, Mail } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { apiClient } from '@/shared/api/client' +import { getApiErrorMessage } from '@/shared/lib/get-api-error-message' +import { tokenSet } from '@/shared/model/auth' + +type AuthResponse = { + token: string + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarStyle?: string | null + } +} + +type FormValues = { + email: string + password: string + passwordConfirm: string + displayName: string +} + +type Props = { + isRegister: boolean + onSuccess: () => void +} + +export function AuthPasswordForm({ isRegister, onSuccess }: Props) { + const { register, watch } = useForm({ + defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' }, + mode: 'onChange', + }) + + const email = watch('email') + const password = watch('password') + const passwordConfirm = watch('passwordConfirm') + const displayName = watch('displayName') + + const loginMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/login', { email, password }) + tokenSet(data.token) + }, + onSuccess, + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/register', { + email, + password, + displayName: displayName || undefined, + }) + tokenSet(data.token) + }, + onSuccess, + }) + + const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + + return ( + + + + + + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + {(loginMutation.error || registerMutation.error) && ( + + )} + + ) +} diff --git a/client/src/pages/auth/__tests__/AuthPage.test.tsx b/client/src/pages/auth/__tests__/AuthPage.test.tsx index 98b3802..765eeb3 100644 --- a/client/src/pages/auth/__tests__/AuthPage.test.tsx +++ b/client/src/pages/auth/__tests__/AuthPage.test.tsx @@ -44,10 +44,16 @@ describe('AuthPage', () => { expect(loginBtn).toBeTruthy() }) - it('switches to register form', () => { + it('switches to code tab', () => { renderPage() - fireEvent.click(screen.getByText('Регистрация')) - expect(screen.getByText('Зарегистрироваться')).toBeTruthy() + fireEvent.click(screen.getByText('Код')) + expect(screen.getByText('Отправить код')).toBeTruthy() + }) + + it('shows auth password form with login by default', () => { + renderPage() + expect(screen.getByText('Вход')).toBeTruthy() + expect(screen.getByText('Регистрация')).toBeTruthy() }) it('switches to code tab', () => { diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 985b653..4d404a2 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -2,151 +2,36 @@ import { useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' -import InputAdornment from '@mui/material/InputAdornment' import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' import { alpha, useTheme } from '@mui/material/styles' -import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' -import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' -import { Lock, Mail } from 'lucide-react' -import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router-dom' +import { AuthCodeForm } from '@/features/auth-code' import { OAuthButtons } from '@/features/auth-oauth' -import { apiClient } from '@/shared/api/client' -import { $user, tokenSet } from '@/shared/model/auth' +import { AuthPasswordForm } from '@/features/auth-password' +import { $user } from '@/shared/model/auth' import { BearLogo } from '@/shared/ui/BearLogo' -type AuthResponse = { - token: string - user: { - id: string - email: string - displayName?: string | null - avatar?: string | null - avatarStyle?: string | null - } -} - -function getApiErrorMessage(err: unknown): string | null { - if (!err || typeof err !== 'object') return null - const anyErr = err as Record - const response = anyErr.response as Record | undefined - const data = response?.data as Record | undefined - const msg = data?.error - return typeof msg === 'string' ? msg : null -} - export function AuthPage() { const theme = useTheme() const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) - const [isRegister, setIsRegister] = useState(false) - const [showForgot, setShowForgot] = useState(false) - const [forgotStep, setForgotStep] = useState(0) - const [forgotEmail, setForgotEmail] = useState('') const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const user = useUnit($user) - const { register, watch } = useForm<{ - email: string - password: string - passwordConfirm: string - displayName: string - code: string - }>({ - defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' }, - mode: 'onChange', - }) - - const email = watch('email') - const password = watch('password') - const passwordConfirm = watch('passwordConfirm') - const code = watch('code') - useEffect(() => { if (user) navigate('/', { replace: true }) }, [navigate, user]) - useEffect(() => { - const err = searchParams.get('oauthError') - if (!err) return - setOauthError(err) + const oauthErrorParam = searchParams.get('oauthError') + if (oauthErrorParam) { + setOauthError(oauthErrorParam) setSearchParams({}, { replace: true }) - }, [searchParams, setSearchParams]) - - const loginMutation = useMutation({ - mutationFn: async () => { - const { data } = await apiClient.post('auth/login', { email, password }) - tokenSet(data.token) - navigate('/', { replace: true }) - }, - }) - - const registerMutation = useMutation({ - mutationFn: async () => { - const { data } = await apiClient.post('auth/register', { - email, - password, - displayName: watch('displayName') || undefined, - }) - tokenSet(data.token) - navigate('/', { replace: true }) - }, - }) - - const requestCode = useMutation({ - mutationFn: async () => { - await apiClient.post('auth/request-code', { email }) - }, - onSuccess: () => setMessage('Код отправлен. Проверьте почту.'), - }) - - const verifyCode = useMutation({ - mutationFn: async () => { - const { data } = await apiClient.post('auth/verify-code', { email, code }) - tokenSet(data.token) - navigate('/', { replace: true }) - }, - }) - - const forgotCode = useMutation({ - mutationFn: async () => { - await apiClient.post('auth/forgot-password', { email: forgotEmail }) - }, - onSuccess: () => { - setForgotStep(1) - setMessage('Код отправлен на почту') - }, - }) - - const resetPassword = useMutation({ - mutationFn: async () => { - await apiClient.post('auth/reset-password', { - email: forgotEmail, - code, - newPassword: password, - }) - }, - onSuccess: () => { - setShowForgot(false) - setForgotStep(0) - setMessage('Пароль изменён. Войдите с новым паролем.') - }, - }) - - const errMsg = - getApiErrorMessage(loginMutation.error) || - getApiErrorMessage(registerMutation.error) || - getApiErrorMessage(requestCode.error) || - getApiErrorMessage(verifyCode.error) || - getApiErrorMessage(forgotCode.error) || - getApiErrorMessage(resetPassword.error) - - const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + } return ( - {(errMsg || oauthError) && ( - { - setOauthError(null) - }} - > - {errMsg || oauthError} + {oauthError && ( + setOauthError(null)}> + {oauthError} )} {message && ( @@ -223,250 +101,8 @@ export function AuthPage() { )} - {tab === 0 && ( - - - - - - - - - - ), - }, - }} - /> - - {isRegister && ( - - )} - - - - - ), - }, - }} - /> - - {isRegister && ( - - )} - - {isRegister ? ( - - ) : ( - - )} - - {!isRegister && !showForgot && ( - - )} - - {showForgot && ( - <> - setForgotEmail(e.target.value)} - fullWidth - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - /> - - {forgotStep === 1 && ( - <> - - { - register('code').onChange(e) - }} - sx={{ flex: 1 }} - /> - - - - - - )} - - {forgotStep === 0 && ( - - )} - - - - )} - - )} - - {tab === 1 && ( - - - - - ), - }, - }} - /> - - - - - - - )} + {tab === 0 && navigate('/', { replace: true })} />} + {tab === 1 && navigate('/', { replace: true })} />} diff --git a/client/src/shared/lib/get-api-error-message.ts b/client/src/shared/lib/get-api-error-message.ts new file mode 100644 index 0000000..c68f6a0 --- /dev/null +++ b/client/src/shared/lib/get-api-error-message.ts @@ -0,0 +1,8 @@ +export function getApiErrorMessage(err: unknown): string | null { + if (!err || typeof err !== 'object') return null + const anyErr = err as Record + const response = anyErr.response as Record | undefined + const data = response?.data as Record | undefined + const msg = data?.error + return typeof msg === 'string' ? msg : null +} From b1530ef7054b7665c5a975e64e76f8e390fbbd33 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:47:06 +0500 Subject: [PATCH 02/10] fix(auth): add forgot password flow and fix OAuth URL clearing --- client/src/features/auth-forgot/index.ts | 1 + .../auth-forgot/ui/AuthForgotForm.tsx | 145 ++++++++++++++++++ client/src/pages/auth/ui/AuthPage.tsx | 50 +++++- 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 client/src/features/auth-forgot/index.ts create mode 100644 client/src/features/auth-forgot/ui/AuthForgotForm.tsx diff --git a/client/src/features/auth-forgot/index.ts b/client/src/features/auth-forgot/index.ts new file mode 100644 index 0000000..81d088a --- /dev/null +++ b/client/src/features/auth-forgot/index.ts @@ -0,0 +1 @@ +export { AuthForgotForm } from './ui/AuthForgotForm' diff --git a/client/src/features/auth-forgot/ui/AuthForgotForm.tsx b/client/src/features/auth-forgot/ui/AuthForgotForm.tsx new file mode 100644 index 0000000..f769891 --- /dev/null +++ b/client/src/features/auth-forgot/ui/AuthForgotForm.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react' +import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation } from '@tanstack/react-query' +import { Lock, Mail } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { apiClient } from '@/shared/api/client' +import { getApiErrorMessage } from '@/shared/lib/get-api-error-message' + +type Step = 'request' | 'reset' + +type FormValues = { + email: string + code: string + newPassword: string + passwordConfirm: string +} + +type Props = { + onBack: () => void +} + +export function AuthForgotForm({ onBack }: Props) { + const [step, setStep] = useState('request') + + const { register, watch } = useForm({ + defaultValues: { email: '', code: '', newPassword: '', passwordConfirm: '' }, + mode: 'onChange', + }) + + const email = watch('email') + const code = watch('code') + const newPassword = watch('newPassword') + const passwordConfirm = watch('passwordConfirm') + + const forgotCodeMutation = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/forgot-password', { email }) + }, + onSuccess: () => setStep('reset'), + }) + + const resetPasswordMutation = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/reset-password', { email, code, newPassword }) + }, + }) + + const passwordError = newPassword && passwordConfirm && newPassword !== passwordConfirm ? 'Пароли не совпадают' : null + + return ( + + + {step === 'request' + ? 'Введите email, на который будет отправлен код для сброса пароля' + : 'Введите код и новый пароль'} + + + + + + ), + }, + }} + /> + + {step === 'reset' && ( + <> + + + + + ), + }, + }} + /> + + + )} + + {step === 'request' ? ( + + ) : ( + + )} + + + + {(forgotCodeMutation.error || resetPasswordMutation.error) && ( + + )} + + ) +} diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 4d404a2..12739bd 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -9,6 +9,7 @@ import Typography from '@mui/material/Typography' import { useUnit } from 'effector-react' import { useNavigate, useSearchParams } from 'react-router-dom' import { AuthCodeForm } from '@/features/auth-code' +import { AuthForgotForm } from '@/features/auth-forgot' import { OAuthButtons } from '@/features/auth-oauth' import { AuthPasswordForm } from '@/features/auth-password' import { $user } from '@/shared/model/auth' @@ -19,6 +20,7 @@ export function AuthPage() { const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) + const [showForgot, setShowForgot] = useState(false) const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const user = useUnit($user) @@ -27,10 +29,46 @@ export function AuthPage() { if (user) navigate('/', { replace: true }) }, [navigate, user]) - const oauthErrorParam = searchParams.get('oauthError') - if (oauthErrorParam) { - setOauthError(oauthErrorParam) + useEffect(() => { + const err = searchParams.get('oauthError') + if (!err) return + setOauthError(err) setSearchParams({}, { replace: true }) + }, [searchParams, setSearchParams]) + + if (showForgot) { + return ( + + + + + + + + Восстановление пароля + + + + setShowForgot(false)} /> + + + + ) } return ( @@ -104,6 +142,12 @@ export function AuthPage() { {tab === 0 && navigate('/', { replace: true })} />} {tab === 1 && navigate('/', { replace: true })} />} + + + + From 03e60e46f396da79e1bbb98b1ea7a6e84e805906 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:50:38 +0500 Subject: [PATCH 03/10] fix(auth): defer setState in OAuth error effect to avoid cascading renders --- client/src/pages/auth/ui/AuthPage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 12739bd..aeeed89 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -32,9 +32,12 @@ export function AuthPage() { useEffect(() => { const err = searchParams.get('oauthError') if (!err) return - setOauthError(err) - setSearchParams({}, { replace: true }) - }, [searchParams, setSearchParams]) + const timeoutId = setTimeout(() => { + setOauthError(err) + setSearchParams({}, { replace: true }) + }, 0) + return () => clearTimeout(timeoutId) + }, []) if (showForgot) { return ( From e273c29c36e4c1ca99024af6d699201796e0d0a3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 15:04:49 +0500 Subject: [PATCH 04/10] refactor(SettingsPage): split into ProfileSection, AvatarSection, AuthMethodsSection - Extract ProfileSection (45 lines): display name form with save button - Extract AvatarSection (114 lines): avatar preview, style selector, generate/save/cancel - Extract AuthMethodsSection (204 lines): auth methods list, set/change password forms - Rewrite SettingsPage as composer (41 lines): composes 3 sections with dividers - Add tests for all 3 sections --- .../me/ui/sections/AuthMethodsSection.tsx | 204 ++++++++++ .../pages/me/ui/sections/AvatarSection.tsx | 114 ++++++ .../pages/me/ui/sections/ProfileSection.tsx | 45 +++ .../src/pages/me/ui/sections/SettingsPage.tsx | 368 +----------------- .../__tests__/AuthMethodsSection.test.tsx | 44 +++ .../sections/__tests__/AvatarSection.test.tsx | 36 ++ .../__tests__/ProfileSection.test.tsx | 23 ++ 7 files changed, 473 insertions(+), 361 deletions(-) create mode 100644 client/src/pages/me/ui/sections/AuthMethodsSection.tsx create mode 100644 client/src/pages/me/ui/sections/AvatarSection.tsx create mode 100644 client/src/pages/me/ui/sections/ProfileSection.tsx create mode 100644 client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx create mode 100644 client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx create mode 100644 client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx new file mode 100644 index 0000000..4bdda00 --- /dev/null +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { apiClient } from '@/shared/api/client' +import { $user, fetchAuthMethodsFx, setPasswordFx, unlinkOAuthFx, type AuthMethod } from '@/shared/model/auth' + +const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } + +export function AuthMethodsSection() { + const user = useUnit($user) + + const [authMethods, setAuthMethods] = useState([]) + const [showSetPassword, setShowSetPassword] = useState(false) + const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ + defaultValues: { password: '', passwordConfirm: '' }, + }) + + useEffect(() => { + fetchAuthMethodsFx() + .then(setAuthMethods) + .catch(() => { + setAuthMethods([]) + }) + }, []) + + const setPasswordMutation = useMutation({ + mutationFn: async (pw: string) => { + await setPasswordFx(pw) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + setShowSetPassword(false) + }, + onError: () => {}, + }) + + const unlinkMutation = useMutation({ + mutationFn: async (provider: 'vk' | 'yandex') => { + await unlinkOAuthFx(provider) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + }, + onError: () => {}, + }) + + const [showChangePassword, setShowChangePassword] = useState(false) + const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({ + defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, + }) + + const changePasswordMutation = useMutation({ + mutationFn: async (params: { oldPassword: string; newPassword: string }) => { + await apiClient.post('me/change-password', params) + }, + onSuccess: () => { + setShowChangePassword(false) + changePasswordForm.reset() + }, + }) + + const linkedCount = useCallback(() => { + return authMethods.filter((m) => m.active).length + }, [authMethods]) + + if (!user) return null + + return ( + + + Методы входа + + + {authMethods.map((m) => ( + + {METHOD_LABELS[m.type] || m.type} + + {m.active && m.type !== 'password' && ( + + )} + {m.active && m.type === 'password' && ( + + )} + {!m.active && m.type === 'password' && ( + + )} + {!m.active && m.type !== 'password' && ( + + )} + + ))} + + + {showSetPassword && ( + + + + + + + + + )} + + {showChangePassword && ( + + + + + + + + + + )} + + ) +} diff --git a/client/src/pages/me/ui/sections/AvatarSection.tsx b/client/src/pages/me/ui/sections/AvatarSection.tsx new file mode 100644 index 0000000..dd5b656 --- /dev/null +++ b/client/src/pages/me/ui/sections/AvatarSection.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { createAvatar } from '@dicebear/core' +import { useUnit } from 'effector-react' +import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' +import { $user, updateProfileFx } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' + +export function AvatarSection() { + const user = useUnit($user) + const pendingProfile = useUnit(updateProfileFx.pending) + + const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) + const [previewSrc, setPreviewSrc] = useState(null) + const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) + + const hasUnsavedPreview = previewSrc !== null + + if (!user) return null + + return ( + + + Аватар + + + + + + + {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} + + + {hasUnsavedPreview && ( + + + + Текущий + + + )} + + + + + Стиль + + + + + + {hasUnsavedPreview && ( + + + + + )} + + ) +} diff --git a/client/src/pages/me/ui/sections/ProfileSection.tsx b/client/src/pages/me/ui/sections/ProfileSection.tsx new file mode 100644 index 0000000..11ff3f1 --- /dev/null +++ b/client/src/pages/me/ui/sections/ProfileSection.tsx @@ -0,0 +1,45 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { $user, updateProfileFx } from '@/shared/model/auth' + +export function ProfileSection() { + const user = useUnit($user) + const pendingProfile = useUnit(updateProfileFx.pending) + + const profileForm = useForm<{ displayName: string }>({ + defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, + mode: 'onChange', + }) + + return ( + + + Профиль + + + + + + + ) +} diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index df6635a..dd4035a 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -1,113 +1,16 @@ -import { useCallback, useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Chip from '@mui/material/Chip' import Divider from '@mui/material/Divider' -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' import Stack from '@mui/material/Stack' -import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' -import { createAvatar } from '@dicebear/core' -import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' -import { useForm } from 'react-hook-form' -import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' -import { - $updateProfileError, - $user, - fetchAuthMethodsFx, - setPasswordFx, - unlinkOAuthFx, - updateProfileFx, - type AuthMethod, -} from '@/shared/model/auth' -import { UserAvatar } from '@/shared/ui/UserAvatar' -import { apiClient } from '@/shared/api/client' -import type { AxiosError } from 'axios' - -function getApiErrorMessage(error: unknown): string | null { - const e = error as AxiosError<{ error?: string }> - const msg = e?.response?.data?.error - return msg ? String(msg) : null -} +import { $user } from '@/shared/model/auth' +import { AuthMethodsSection } from './AuthMethodsSection' +import { AvatarSection } from './AvatarSection' +import { ProfileSection } from './ProfileSection' export function SettingsPage() { const user = useUnit($user) - const pendingProfile = useUnit(updateProfileFx.pending) - const errorProfile = useUnit($updateProfileError) - - const profileForm = useForm<{ displayName: string }>({ - defaultValues: { - displayName: user?.displayName ? String(user.displayName) : '', - }, - mode: 'onChange', - }) - - const profileErrorMsg = getApiErrorMessage(errorProfile) - - const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) - const [previewSrc, setPreviewSrc] = useState(null) - const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) - - const hasUnsavedPreview = previewSrc !== null - - const [authMethods, setAuthMethods] = useState([]) - const [showSetPassword, setShowSetPassword] = useState(false) - const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ - defaultValues: { password: '', passwordConfirm: '' }, - }) - - useEffect(() => { - fetchAuthMethodsFx() - .then(setAuthMethods) - .catch(() => { - setAuthMethods([]) - }) - }, []) - - const setPasswordMutation = useMutation({ - mutationFn: async (pw: string) => { - await setPasswordFx(pw) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - setShowSetPassword(false) - }, - onError: () => {}, - }) - - const unlinkMutation = useMutation({ - mutationFn: async (provider: 'vk' | 'yandex') => { - await unlinkOAuthFx(provider) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - }, - onError: () => {}, - }) - - const [showChangePassword, setShowChangePassword] = useState(false) - const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({ - defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, - }) - - const changePasswordMutation = useMutation({ - mutationFn: async (params: { oldPassword: string; newPassword: string }) => { - await apiClient.post('me/change-password', params) - }, - onSuccess: () => { - setShowChangePassword(false) - changePasswordForm.reset() - }, - }) - - const linkedCount = useCallback(() => { - return authMethods.filter((m) => m.active).length - }, [authMethods]) - - const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } if (!user) { return Нужно войти. Перейдите на страницу «Вход». @@ -122,271 +25,14 @@ export function SettingsPage() { Текущая почта: {user.email} - {profileErrorMsg && ( - - {profileErrorMsg} - - )} - - - - Профиль - - - - - - - + - - - - Аватар - - - - - - - {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} - - - {hasUnsavedPreview && ( - - - - Текущий - - - )} - - - - - Стиль - - - - - - {hasUnsavedPreview && ( - - - - - )} - - + {!user.isAdmin && ( <> - - - Методы входа - - - {authMethods.map((m) => ( - - {METHOD_LABELS[m.type] || m.type} - - {m.active && m.type !== 'password' && ( - - )} - {m.active && m.type === 'password' && ( - - )} - {!m.active && m.type === 'password' && ( - - )} - {!m.active && m.type !== 'password' && ( - - )} - - ))} - - - {showSetPassword && ( - - - - - - - - - )} - - {showChangePassword && ( - - - - - - - - - - )} - + )} diff --git a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx new file mode 100644 index 0000000..06c425d --- /dev/null +++ b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx @@ -0,0 +1,44 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AuthMethodsSection } from '../AuthMethodsSection' + +vi.mock('@/shared/model/auth', () => ({ + $user: { + defaultState: { id: '1', email: 'test@test.com' }, + subscribe: () => () => {}, + getState: () => ({ id: '1', email: 'test@test.com' }), + watch: () => () => {}, + on: () => {}, + reset: () => {}, + }, + fetchAuthMethodsFx: vi.fn().mockResolvedValue([]), + setPasswordFx: vi.fn(), + unlinkOAuthFx: vi.fn(), +})) + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) + +vi.mock('effector-react', async () => { + return { + useUnit: () => ({ id: '1', email: 'test@test.com' }), + } +}) + +function renderSection() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + , + ) +} + +describe('AuthMethodsSection', () => { + it('renders auth methods section', async () => { + renderSection() + await waitFor(() => { + expect(screen.getByText('Методы входа')).toBeTruthy() + }) + }) +}) diff --git a/client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx new file mode 100644 index 0000000..447dc34 --- /dev/null +++ b/client/src/pages/me/ui/sections/__tests__/AvatarSection.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AvatarSection } from '../AvatarSection' + +vi.mock('@/shared/model/auth', () => ({ + $user: { + defaultState: { id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }, + subscribe: () => () => {}, + getState: () => ({ id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }), + watch: () => () => {}, + on: () => {}, + reset: () => {}, + }, + updateProfileFx: { pending: false }, +})) + +vi.mock('effector-react', async () => { + return { + useUnit: () => ({ id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }), + } +}) + +vi.mock('@dicebear/core', () => ({ + createAvatar: vi.fn(() => ({ + toDataUri: () => 'data:image/svg+xml,', + })), +})) + +describe('AvatarSection', () => { + it('renders avatar section', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Аватар')).toBeTruthy() + }) + }) +}) diff --git a/client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx new file mode 100644 index 0000000..0f3d448 --- /dev/null +++ b/client/src/pages/me/ui/sections/__tests__/ProfileSection.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ProfileSection } from '../ProfileSection' + +vi.mock('@/shared/model/auth', () => ({ + $user: null, + $updateProfileError: null, + updateProfileFx: { pending: false }, +})) + +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => null } +}) + +describe('ProfileSection', () => { + it('renders profile section', () => { + render() + expect(screen.getByText('Профиль')).toBeTruthy() + expect(screen.getByLabelText('Имя или ник')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Сохранить' })).toBeTruthy() + }) +}) From fa276eb7f3505f81910fc08ff6f007202b17247c Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 15:10:20 +0500 Subject: [PATCH 05/10] fix(settings): use $updateProfileError and changePasswordFx per spec --- .../src/pages/me/ui/sections/AuthMethodsSection.tsx | 12 +++++++++--- client/src/pages/me/ui/sections/ProfileSection.tsx | 9 ++++++++- client/src/shared/model/auth.ts | 4 ++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx index 4bdda00..4c36f4f 100644 --- a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -8,8 +8,14 @@ import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' -import { apiClient } from '@/shared/api/client' -import { $user, fetchAuthMethodsFx, setPasswordFx, unlinkOAuthFx, type AuthMethod } from '@/shared/model/auth' +import { + $user, + changePasswordFx, + fetchAuthMethodsFx, + setPasswordFx, + unlinkOAuthFx, + type AuthMethod, +} from '@/shared/model/auth' const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } @@ -56,7 +62,7 @@ export function AuthMethodsSection() { const changePasswordMutation = useMutation({ mutationFn: async (params: { oldPassword: string; newPassword: string }) => { - await apiClient.post('me/change-password', params) + await changePasswordFx(params) }, onSuccess: () => { setShowChangePassword(false) diff --git a/client/src/pages/me/ui/sections/ProfileSection.tsx b/client/src/pages/me/ui/sections/ProfileSection.tsx index 11ff3f1..01db0d8 100644 --- a/client/src/pages/me/ui/sections/ProfileSection.tsx +++ b/client/src/pages/me/ui/sections/ProfileSection.tsx @@ -1,3 +1,4 @@ +import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Stack from '@mui/material/Stack' @@ -5,11 +6,12 @@ import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' -import { $user, updateProfileFx } from '@/shared/model/auth' +import { $user, $updateProfileError, updateProfileFx } from '@/shared/model/auth' export function ProfileSection() { const user = useUnit($user) const pendingProfile = useUnit(updateProfileFx.pending) + const updateProfileError = useUnit($updateProfileError) const profileForm = useForm<{ displayName: string }>({ defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, @@ -28,6 +30,11 @@ export function ProfileSection() { slotProps={{ htmlInput: { maxLength: 40 } }} {...profileForm.register('displayName')} /> + {updateProfileError && ( + + {updateProfileError} + + )}