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