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-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/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..aeeed89 100644
--- a/client/src/pages/auth/ui/AuthPage.tsx
+++ b/client/src/pages/auth/ui/AuthPage.tsx
@@ -2,71 +2,29 @@ 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 { AuthForgotForm } from '@/features/auth-forgot'
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])
@@ -74,79 +32,47 @@ 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)
+ }, [])
- const loginMutation = useMutation({
- mutationFn: async () => {
- const { data } = await apiClient.post('auth/login', { email, password })
- tokenSet(data.token)
- navigate('/', { replace: true })
- },
- })
+ if (showForgot) {
+ return (
+
+
+
+
+
- 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
+
+ setShowForgot(false)} />
+
+
+
+ )
+ }
return (
- {(errMsg || oauthError) && (
- {
- setOauthError(null)
- }}
- >
- {errMsg || oauthError}
+ {oauthError && (
+ setOauthError(null)}>
+ {oauthError}
)}
{message && (
@@ -223,250 +142,14 @@ export function AuthPage() {
)}
- {tab === 0 && (
-
-
-
-
-
+ {tab === 0 && navigate('/', { replace: true })} />}
+ {tab === 1 && navigate('/', { replace: true })} />}
-
-
-
- ),
- },
- }}
- />
-
- {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 && (
-
-
-
-
- ),
- },
- }}
- />
-
-
-
-
-
-
- )}
+
+
+
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..79ae7f1
--- /dev/null
+++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx
@@ -0,0 +1,218 @@
+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 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 {
+ $user,
+ changePasswordFx,
+ 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 [fetchError, setFetchError] = useState(null)
+ const passwordForm = useForm<{ password: string; passwordConfirm: string }>({
+ defaultValues: { password: '', passwordConfirm: '' },
+ })
+
+ useEffect(() => {
+ fetchAuthMethodsFx()
+ .then(setAuthMethods)
+ .catch((err) => {
+ setAuthMethods([])
+ setFetchError(err?.message || 'Не удалось загрузить методы авторизации')
+ })
+ }, [])
+
+ 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 changePasswordFx(params)
+ },
+ onSuccess: () => {
+ setShowChangePassword(false)
+ changePasswordForm.reset()
+ },
+ })
+
+ const linkedCount = useCallback(() => {
+ return authMethods.filter((m) => m.active).length
+ }, [authMethods])
+
+ if (!user) return null
+
+ return (
+
+
+ Методы входа
+
+ {fetchError && (
+
+ {fetchError}
+
+ )}
+
+ {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..2e4b971
--- /dev/null
+++ b/client/src/pages/me/ui/sections/AvatarSection.tsx
@@ -0,0 +1,119 @@
+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..c167299
--- /dev/null
+++ b/client/src/pages/me/ui/sections/ProfileSection.tsx
@@ -0,0 +1,57 @@
+import { useEffect } from 'react'
+import Alert from '@mui/material/Alert'
+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, $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) : '' },
+ mode: 'onChange',
+ })
+
+ useEffect(() => {
+ profileForm.reset({ displayName: user?.displayName ? String(user.displayName) : '' })
+ }, [user?.displayName, profileForm])
+
+ return (
+
+
+ Профиль
+
+
+
+ {updateProfileError && (
+
+ {updateProfileError}
+
+ )}
+
+
+
+ )
+}
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()
+ })
+})
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
+}
diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts
index cf018fc..542b83e 100644
--- a/client/src/shared/model/auth.ts
+++ b/client/src/shared/model/auth.ts
@@ -100,6 +100,10 @@ export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => {
await apiClient.delete(`me/oauth/${provider}`)
})
+export const changePasswordFx = createEffect(async (params: { oldPassword: string; newPassword: string }) => {
+ await apiClient.post('me/change-password', params)
+})
+
// ----- Error stores -----
export const $updateProfileError = createErrorStore(updateProfileFx).$error
diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db
index f9777dd..bda9e90 100644
Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ
diff --git a/server/src/index.js b/server/src/index.js
index 2f1477e..912e003 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -90,7 +90,6 @@ fastify.decorate('eventBus', eventBus)
fastify.decorate('notificationQueue', notificationQueue)
registerAuth(fastify)
-await registerAuthRoutes(fastify)
await registerUserAddressRoutes(fastify)
await registerUserCartRoutes(fastify)
await registerUserMessageRoutes(fastify)
diff --git a/server/src/routes/__tests__/auth-methods.test.js b/server/src/routes/__tests__/auth-methods.test.js
index f02b898..47ef17c 100644
--- a/server/src/routes/__tests__/auth-methods.test.js
+++ b/server/src/routes/__tests__/auth-methods.test.js
@@ -2,7 +2,7 @@ import jwt from '@fastify/jwt'
import Fastify from 'fastify'
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
import { prisma } from '../../lib/prisma.js'
-import { registerAuthRoutes } from '../auth.js'
+import { registerAuthSessionRoutes } from '../auth-session.js'
const JWT_SECRET = 'test-secret'
@@ -17,7 +17,7 @@ async function buildApp() {
}
})
app.decorate('eventBus', { emit: () => {} })
- await registerAuthRoutes(app)
+ await registerAuthSessionRoutes(app)
await app.ready()
return app
}
@@ -78,108 +78,3 @@ describe('GET /api/me/auth-methods', () => {
expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true)
})
})
-
-describe('POST /api/me/password', () => {
- let app, user, token
- const email = `test-set-pw-${Date.now()}@example.com`
-
- beforeAll(async () => {
- app = await buildApp()
- })
- afterAll(async () => {
- await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
- await prisma.user.deleteMany({ where: { email } })
- await app.close()
- })
-
- beforeEach(async () => {
- await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
- await prisma.user.deleteMany({ where: { email } })
- user = await createUser(email)
- token = signToken(app, user.id, email)
- })
-
- it('sets password', async () => {
- const res = await app.inject({
- method: 'POST',
- url: '/api/me/password',
- headers: { authorization: `Bearer ${token}` },
- payload: { password: 'Test123!@' },
- })
- expect(res.statusCode).toBe(200)
-
- const u = await prisma.user.findUnique({ where: { id: user.id } })
- expect(u.passwordHash).toBeTruthy()
- })
-
- it('rejects if password already set', async () => {
- await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } })
- const res = await app.inject({
- method: 'POST',
- url: '/api/me/password',
- headers: { authorization: `Bearer ${token}` },
- payload: { password: 'Test123!@' },
- })
- expect(res.statusCode).toBe(409)
- })
-})
-
-describe('DELETE /api/me/oauth/:provider', () => {
- let app, user, token
- const email = `test-unlink-${Date.now()}@example.com`
-
- beforeAll(async () => {
- app = await buildApp()
- })
- afterAll(async () => {
- await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
- await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
- await prisma.user.deleteMany({ where: { email } })
- await app.close()
- })
-
- beforeEach(async () => {
- await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
- await prisma.user.deleteMany({ where: { email } })
- user = await createUser(email)
- token = signToken(app, user.id, email)
- })
-
- it('returns 404 for non-linked provider', async () => {
- const res = await app.inject({
- method: 'DELETE',
- url: '/api/me/oauth/vk',
- headers: { authorization: `Bearer ${token}` },
- })
- expect(res.statusCode).toBe(404)
- })
-
- it('unlinks a provider', async () => {
- await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
- await prisma.oAuthAccount.create({
- data: { provider: 'vk', providerUserId: '123', userId: user.id },
- })
- const res = await app.inject({
- method: 'DELETE',
- url: '/api/me/oauth/vk',
- headers: { authorization: `Bearer ${token}` },
- })
- expect(res.statusCode).toBe(200)
-
- const count = await prisma.oAuthAccount.count({ where: { userId: user.id } })
- expect(count).toBe(0)
- })
-
- it('rejects removing last method without password', async () => {
- await prisma.oAuthAccount.create({
- data: { provider: 'vk', providerUserId: '123', userId: user.id },
- })
- const res = await app.inject({
- method: 'DELETE',
- url: '/api/me/oauth/vk',
- headers: { authorization: `Bearer ${token}` },
- })
- expect(res.statusCode).toBe(400)
- expect(JSON.parse(res.body).error).toContain('последний метод')
- })
-})
diff --git a/server/src/routes/__tests__/auth-oauth.test.js b/server/src/routes/__tests__/auth-oauth.test.js
new file mode 100644
index 0000000..447755f
--- /dev/null
+++ b/server/src/routes/__tests__/auth-oauth.test.js
@@ -0,0 +1,95 @@
+import jwt from '@fastify/jwt'
+import Fastify from 'fastify'
+import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
+import { prisma } from '../../lib/prisma.js'
+import { registerAuthOAuthRoutes } from '../auth-oauth.js'
+
+const JWT_SECRET = 'test-secret'
+
+async function buildApp() {
+ const app = Fastify({ logger: false })
+ await app.register(jwt, { secret: JWT_SECRET })
+ app.decorate('authenticate', async function (request, reply) {
+ try {
+ await request.jwtVerify()
+ } catch {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ })
+ app.decorate('eventBus', { emit: () => {} })
+ await registerAuthOAuthRoutes(app)
+ await app.ready()
+ return app
+}
+
+function signToken(app, userId, email) {
+ return app.jwt.sign({ sub: userId, email })
+}
+
+async function createUser(email) {
+ const user = await prisma.user.create({
+ data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
+ })
+ await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
+ return user
+}
+
+describe('DELETE /api/me/oauth/:provider', () => {
+ let app, user, token
+ const email = `test-unlink-${Date.now()}@example.com`
+
+ beforeAll(async () => {
+ app = await buildApp()
+ })
+ afterAll(async () => {
+ await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
+ await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
+ await prisma.user.deleteMany({ where: { email } })
+ await app.close()
+ })
+
+ beforeEach(async () => {
+ await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
+ await prisma.user.deleteMany({ where: { email } })
+ user = await createUser(email)
+ token = signToken(app, user.id, email)
+ })
+
+ it('returns 404 for non-linked provider', async () => {
+ const res = await app.inject({
+ method: 'DELETE',
+ url: '/api/me/oauth/vk',
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(res.statusCode).toBe(404)
+ })
+
+ it('unlinks a provider', async () => {
+ await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
+ await prisma.oAuthAccount.create({
+ data: { provider: 'vk', providerUserId: '123', userId: user.id },
+ })
+ const res = await app.inject({
+ method: 'DELETE',
+ url: '/api/me/oauth/vk',
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(res.statusCode).toBe(200)
+
+ const count = await prisma.oAuthAccount.count({ where: { userId: user.id } })
+ expect(count).toBe(0)
+ })
+
+ it('rejects removing last method without password', async () => {
+ await prisma.oAuthAccount.create({
+ data: { provider: 'vk', providerUserId: '123', userId: user.id },
+ })
+ const res = await app.inject({
+ method: 'DELETE',
+ url: '/api/me/oauth/vk',
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(res.statusCode).toBe(400)
+ expect(JSON.parse(res.body).error).toContain('последний метод')
+ })
+})
diff --git a/server/src/routes/__tests__/auth-password.test.js b/server/src/routes/__tests__/auth-password.test.js
index db6cbf9..238f927 100644
--- a/server/src/routes/__tests__/auth-password.test.js
+++ b/server/src/routes/__tests__/auth-password.test.js
@@ -1,12 +1,10 @@
import jwt from '@fastify/jwt'
import Fastify from 'fastify'
-import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
import { prisma } from '../../lib/prisma.js'
-import { registerAuthRoutes } from '../auth.js'
+import { registerAuthPasswordRoutes } from '../auth-password.js'
const JWT_SECRET = 'test-secret'
-const TEST_EMAIL = `test-reg-${Date.now()}@example.com`
-const LOGIN_EMAIL = `test-login-${Date.now()}@example.com`
async function buildApp() {
const app = Fastify({ logger: false })
@@ -19,129 +17,109 @@ async function buildApp() {
}
})
app.decorate('eventBus', { emit: () => {} })
- await registerAuthRoutes(app)
+ await registerAuthPasswordRoutes(app)
await app.ready()
return app
}
-describe('POST /api/auth/register', () => {
- let app
+function signToken(app, userId, email) {
+ return app.jwt.sign({ sub: userId, email })
+}
+
+async function createUser(email) {
+ const user = await prisma.user.create({
+ data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
+ })
+ await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
+ return user
+}
+
+describe('POST /api/me/password', () => {
+ let app, user, token
+ const email = `test-set-pw-${Date.now()}@example.com`
+
beforeAll(async () => {
app = await buildApp()
})
afterAll(async () => {
+ await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
+ await prisma.user.deleteMany({ where: { email } })
await app.close()
})
- afterEach(async () => {
- await prisma.authCode.deleteMany({ where: { email: TEST_EMAIL } })
- await prisma.notificationPreference.deleteMany({ where: { user: { email: TEST_EMAIL } } })
- await prisma.user.deleteMany({ where: { email: TEST_EMAIL } })
+
+ beforeEach(async () => {
+ await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
+ await prisma.user.deleteMany({ where: { email } })
+ user = await createUser(email)
+ token = signToken(app, user.id, email)
})
- it('registers a new user with password', async () => {
+ it('sets password', async () => {
const res = await app.inject({
method: 'POST',
- url: '/api/auth/register',
- payload: { email: TEST_EMAIL, password: 'Test123!@' },
+ url: '/api/me/password',
+ headers: { authorization: `Bearer ${token}` },
+ payload: { password: 'Test123!@' },
})
- expect(res.statusCode).toBe(201)
- const body = JSON.parse(res.body)
- expect(body.token).toBeTruthy()
- expect(body.user.email).toBe(TEST_EMAIL)
+ expect(res.statusCode).toBe(200)
+
+ const u = await prisma.user.findUnique({ where: { id: user.id } })
+ expect(u.passwordHash).toBeTruthy()
})
- it('rejects duplicate email', async () => {
- await app.inject({
- method: 'POST',
- url: '/api/auth/register',
- payload: { email: TEST_EMAIL, password: 'Test123!@' },
- })
+ it('rejects if password already set', async () => {
+ await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } })
const res = await app.inject({
method: 'POST',
- url: '/api/auth/register',
- payload: { email: TEST_EMAIL, password: 'Test123!@' },
+ url: '/api/me/password',
+ headers: { authorization: `Bearer ${token}` },
+ payload: { password: 'Test123!@' },
})
expect(res.statusCode).toBe(409)
})
-
- it('rejects weak password — too short', async () => {
- const res = await app.inject({
- method: 'POST',
- url: '/api/auth/register',
- payload: { email: TEST_EMAIL, password: 'Ab1!' },
- })
- expect(res.statusCode).toBe(400)
- const body = JSON.parse(res.body)
- expect(body.error).toContain('не менее 8')
- })
-
- it('rejects weak password — no digit', async () => {
- const res = await app.inject({
- method: 'POST',
- url: '/api/auth/register',
- payload: { email: TEST_EMAIL, password: 'Abcdefgh!' },
- })
- expect(res.statusCode).toBe(400)
- expect(JSON.parse(res.body).error).toContain('цифру')
- })
-
- it('rejects weak password — no special char', async () => {
- const res = await app.inject({
- method: 'POST',
- url: '/api/auth/register',
- payload: { email: TEST_EMAIL, password: 'Abcdefg1' },
- })
- expect(res.statusCode).toBe(400)
- expect(JSON.parse(res.body).error).toContain('спецсимвол')
- })
})
-describe('POST /api/auth/login', () => {
- let app
+describe('POST /api/me/change-password', () => {
+ let app, user, token
+ const email = `test-change-pw-${Date.now()}@example.com`
+
beforeAll(async () => {
app = await buildApp()
- await app.inject({
- method: 'POST',
- url: '/api/auth/register',
- payload: { email: LOGIN_EMAIL, password: 'Test123!@' },
- })
})
afterAll(async () => {
- await prisma.authCode.deleteMany({ where: { email: LOGIN_EMAIL } })
- await prisma.notificationPreference.deleteMany({ where: { user: { email: LOGIN_EMAIL } } })
- await prisma.oAuthAccount.deleteMany({ where: { user: { email: LOGIN_EMAIL } } })
- await prisma.user.deleteMany({ where: { email: LOGIN_EMAIL } })
+ await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
+ await prisma.user.deleteMany({ where: { email } })
await app.close()
})
- it('logs in with correct password', async () => {
- const res = await app.inject({
- method: 'POST',
- url: '/api/auth/login',
- payload: { email: LOGIN_EMAIL, password: 'Test123!@' },
- headers: { 'x-forwarded-for': '1.1.1.1' },
- })
- expect(res.statusCode).toBe(200)
- expect(JSON.parse(res.body).token).toBeTruthy()
+ beforeEach(async () => {
+ await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
+ await prisma.user.deleteMany({ where: { email } })
+ user = await createUser(email)
+ token = signToken(app, user.id, email)
})
- it('rejects wrong password', async () => {
+ it('changes password', async () => {
+ await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'oldhash' } })
const res = await app.inject({
method: 'POST',
- url: '/api/auth/login',
- payload: { email: LOGIN_EMAIL, password: 'Wrong!!1!' },
- headers: { 'x-forwarded-for': '2.2.2.2' },
+ url: '/api/me/change-password',
+ headers: { authorization: `Bearer ${token}` },
+ payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
})
expect(res.statusCode).toBe(401)
+
+ const u = await prisma.user.findUnique({ where: { id: user.id } })
+ expect(u.passwordHash).toBe('oldhash')
})
- it('rejects non-existent email', async () => {
+ it('rejects if no password set', async () => {
const res = await app.inject({
method: 'POST',
- url: '/api/auth/login',
- payload: { email: 'nobody@nowhere.test', password: 'Test123!@' },
- headers: { 'x-forwarded-for': '3.3.3.3' },
+ url: '/api/me/change-password',
+ headers: { authorization: `Bearer ${token}` },
+ payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
})
- expect(res.statusCode).toBe(401)
+ expect(res.statusCode).toBe(400)
})
})
diff --git a/server/src/routes/__tests__/auth-session.test.js b/server/src/routes/__tests__/auth-session.test.js
new file mode 100644
index 0000000..185469f
--- /dev/null
+++ b/server/src/routes/__tests__/auth-session.test.js
@@ -0,0 +1,121 @@
+import jwt from '@fastify/jwt'
+import Fastify from 'fastify'
+import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
+import { prisma } from '../../lib/prisma.js'
+import { registerAuthSessionRoutes } from '../auth-session.js'
+
+const JWT_SECRET = 'test-secret'
+
+async function buildApp() {
+ const app = Fastify({ logger: false })
+ await app.register(jwt, { secret: JWT_SECRET })
+ app.decorate('authenticate', async function (request, reply) {
+ try {
+ await request.jwtVerify()
+ } catch {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ })
+ app.decorate('eventBus', { emit: () => {} })
+ await registerAuthSessionRoutes(app)
+ await app.ready()
+ return app
+}
+
+function signToken(app, userId, email) {
+ return app.jwt.sign({ sub: userId, email })
+}
+
+async function createUser(email) {
+ const user = await prisma.user.create({
+ data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
+ })
+ await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
+ return user
+}
+
+describe('GET /api/me', () => {
+ let app, user, token
+ const email = `test-me-${Date.now()}@example.com`
+
+ beforeAll(async () => {
+ app = await buildApp()
+ })
+ afterAll(async () => {
+ await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
+ await prisma.user.deleteMany({ where: { email } })
+ await app.close()
+ })
+
+ beforeEach(async () => {
+ await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
+ await prisma.user.deleteMany({ where: { email } })
+ user = await createUser(email)
+ token = signToken(app, user.id, email)
+ })
+
+ it('returns current user', async () => {
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/me',
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(res.statusCode).toBe(200)
+ const body = JSON.parse(res.body)
+ expect(body.user.email).toBe(email)
+ expect(body.user.displayName).toBe('Test')
+ })
+
+ it('returns 401 without token', async () => {
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/me',
+ })
+ expect(res.statusCode).toBe(401)
+ })
+})
+
+describe('GET /api/me/auth-methods', () => {
+ let app, user, token
+ const email = `test-methods-${Date.now()}@example.com`
+
+ beforeAll(async () => {
+ app = await buildApp()
+ })
+ afterAll(async () => {
+ await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
+ await prisma.user.deleteMany({ where: { email } })
+ await app.close()
+ })
+
+ beforeEach(async () => {
+ await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
+ await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
+ await prisma.user.deleteMany({ where: { email } })
+ user = await createUser(email)
+ token = signToken(app, user.id, email)
+ })
+
+ it('returns methods for user without any method', async () => {
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/me/auth-methods',
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(res.statusCode).toBe(200)
+ const body = JSON.parse(res.body)
+ expect(body.methods.find((m) => m.type === 'password').active).toBe(false)
+ expect(body.methods.find((m) => m.type === 'vk').active).toBe(false)
+ expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false)
+ })
+
+ it('returns password as active after setting it', async () => {
+ await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/me/auth-methods',
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true)
+ })
+})
diff --git a/server/src/routes/api.js b/server/src/routes/api.js
index 82554d9..9f3fe93 100644
--- a/server/src/routes/api.js
+++ b/server/src/routes/api.js
@@ -10,6 +10,10 @@ import { registerAdminUserRoutes } from './api/admin-users.js'
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js'
+import { registerAuthRoutes } from './auth.js'
+import { registerAuthOAuthRoutes } from './auth-oauth.js'
+import { registerAuthPasswordRoutes } from './auth-password.js'
+import { registerAuthSessionRoutes } from './auth-session.js'
export async function registerApiRoutes(fastify) {
fastify.decorate('slugify', slugify)
@@ -28,4 +32,9 @@ export async function registerApiRoutes(fastify) {
await registerAdminUserRoutes(fastify)
await registerAdminNotificationRoutes(fastify)
await registerAdminProfileRoutes(fastify)
+
+ await registerAuthRoutes(fastify)
+ await registerAuthSessionRoutes(fastify)
+ await registerAuthPasswordRoutes(fastify)
+ await registerAuthOAuthRoutes(fastify)
}
diff --git a/server/src/routes/auth-oauth.js b/server/src/routes/auth-oauth.js
new file mode 100644
index 0000000..fbedc2b
--- /dev/null
+++ b/server/src/routes/auth-oauth.js
@@ -0,0 +1,35 @@
+import { isAdminEmail } from '../lib/auth.js'
+import { prisma } from '../lib/prisma.js'
+
+export async function registerAuthOAuthRoutes(fastify) {
+ fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => {
+ const userId = request.user.sub
+ const provider = request.params?.provider
+
+ if (isAdminEmail(request.user.email)) {
+ return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' })
+ }
+ if (provider !== 'vk' && provider !== 'yandex') {
+ return reply.code(400).send({ error: 'Неизвестный провайдер' })
+ }
+
+ const oauth = await prisma.oAuthAccount.findFirst({
+ where: { userId, provider },
+ })
+ if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' })
+
+ const remainingOAuth = await prisma.oAuthAccount.count({
+ where: { userId, provider: { not: provider } },
+ })
+ const currentUser = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { passwordHash: true },
+ })
+ if (!currentUser?.passwordHash && remainingOAuth === 0) {
+ return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' })
+ }
+
+ await prisma.oAuthAccount.delete({ where: { id: oauth.id } })
+ return { ok: true }
+ })
+}
diff --git a/server/src/routes/auth-password.js b/server/src/routes/auth-password.js
new file mode 100644
index 0000000..3520229
--- /dev/null
+++ b/server/src/routes/auth-password.js
@@ -0,0 +1,49 @@
+import { comparePassword, hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js'
+import { prisma } from '../lib/prisma.js'
+
+export async function registerAuthPasswordRoutes(fastify) {
+ fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
+ const userId = request.user.sub
+ if (isAdminEmail(request.user.email)) {
+ return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' })
+ }
+
+ const user = await prisma.user.findUnique({ where: { id: userId } })
+ if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
+ if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' })
+
+ const password = String(request.body?.password || '')
+ const passwordErr = validatePassword(password)
+ if (passwordErr) return reply.code(400).send({ error: passwordErr })
+
+ const passwordHash = await hashPassword(password)
+ await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
+
+ return { ok: true }
+ })
+
+ fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
+ const userId = request.user.sub
+ if (isAdminEmail(request.user.email)) {
+ return reply.code(403).send({ error: 'Администратор не может менять пароль' })
+ }
+
+ const user = await prisma.user.findUnique({ where: { id: userId } })
+ if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
+ if (!user.passwordHash)
+ return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' })
+
+ const oldPassword = String(request.body?.oldPassword || '')
+ const valid = await comparePassword(oldPassword, user.passwordHash)
+ if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' })
+
+ const newPassword = String(request.body?.newPassword || '')
+ const passwordErr = validatePassword(newPassword)
+ if (passwordErr) return reply.code(400).send({ error: passwordErr })
+
+ const passwordHash = await hashPassword(newPassword)
+ await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
+
+ return { ok: true }
+ })
+}
diff --git a/server/src/routes/auth-session.js b/server/src/routes/auth-session.js
new file mode 100644
index 0000000..636f712
--- /dev/null
+++ b/server/src/routes/auth-session.js
@@ -0,0 +1,29 @@
+import { prisma } from '../lib/prisma.js'
+import { mapUserForClient } from './auth.js'
+
+export async function registerAuthSessionRoutes(fastify) {
+ fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
+ const userId = request.user.sub
+ const user = await prisma.user.findUnique({ where: { id: userId } })
+ if (!user) return { user: null }
+ return { user: mapUserForClient(user) }
+ })
+
+ fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => {
+ const userId = request.user.sub
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ include: { oauthAccounts: { select: { provider: true } } },
+ })
+ if (!user) return { methods: [] }
+
+ const providers = user.oauthAccounts.map((a) => a.provider)
+ return {
+ methods: [
+ { type: 'password', active: Boolean(user.passwordHash) },
+ { type: 'vk', active: providers.includes('vk') },
+ { type: 'yandex', active: providers.includes('yandex') },
+ ],
+ }
+ })
+}
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index 004df4c..0fe9e20 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -11,7 +11,7 @@ import {
import { prisma } from '../lib/prisma.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js'
-function mapUserForClient(user) {
+export function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
const userEmail = normalizeEmail(user.email)
return {
@@ -171,107 +171,6 @@ export async function registerAuthRoutes(fastify) {
return { ok: true }
})
- fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
- const userId = request.user.sub
- const user = await prisma.user.findUnique({ where: { id: userId } })
- if (!user) return { user: null }
- return { user: mapUserForClient(user) }
- })
-
- fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => {
- const userId = request.user.sub
- const user = await prisma.user.findUnique({
- where: { id: userId },
- include: { oauthAccounts: { select: { provider: true } } },
- })
- if (!user) return { methods: [] }
-
- const providers = user.oauthAccounts.map((a) => a.provider)
- return {
- methods: [
- { type: 'password', active: Boolean(user.passwordHash) },
- { type: 'vk', active: providers.includes('vk') },
- { type: 'yandex', active: providers.includes('yandex') },
- ],
- }
- })
-
- fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
- const userId = request.user.sub
- if (isAdminEmail(request.user.email)) {
- return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' })
- }
-
- const user = await prisma.user.findUnique({ where: { id: userId } })
- if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
- if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' })
-
- const password = String(request.body?.password || '')
- const passwordErr = validatePassword(password)
- if (passwordErr) return reply.code(400).send({ error: passwordErr })
-
- const passwordHash = await hashPassword(password)
- await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
-
- return { ok: true }
- })
-
- fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
- const userId = request.user.sub
- if (isAdminEmail(request.user.email)) {
- return reply.code(403).send({ error: 'Администратор не может менять пароль' })
- }
-
- const user = await prisma.user.findUnique({ where: { id: userId } })
- if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
- if (!user.passwordHash) return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' })
-
- const oldPassword = String(request.body?.oldPassword || '')
- const valid = await comparePassword(oldPassword, user.passwordHash)
- if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' })
-
- const newPassword = String(request.body?.newPassword || '')
- const passwordErr = validatePassword(newPassword)
- if (passwordErr) return reply.code(400).send({ error: passwordErr })
-
- const passwordHash = await hashPassword(newPassword)
- await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
-
- return { ok: true }
- })
-
- fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => {
- const userId = request.user.sub
- const provider = request.params?.provider
-
- if (isAdminEmail(request.user.email)) {
- return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' })
- }
- if (provider !== 'vk' && provider !== 'yandex') {
- return reply.code(400).send({ error: 'Неизвестный провайдер' })
- }
-
- const oauth = await prisma.oAuthAccount.findFirst({
- where: { userId, provider },
- })
- if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' })
-
- const remainingOAuth = await prisma.oAuthAccount.count({
- where: { userId, provider: { not: provider } },
- })
- const currentUser = await prisma.user.findUnique({
- where: { id: userId },
- select: { passwordHash: true },
- })
- if (!currentUser?.passwordHash && remainingOAuth === 0) {
- return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' })
- }
-
- await prisma.oAuthAccount.delete({ where: { id: oauth.id } })
- return { ok: true }
- })
-
-
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
const nameRaw = request.body?.displayName