From 68bbbf8895d6196f67218669dc6e8f546fe2b64f Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:36:19 +0500 Subject: [PATCH] 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 +}