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
+}