Merge branch 'refac2'
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export { AuthCodeForm } from './ui/AuthCodeForm'
|
||||||
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthCodeForm onSuccess={onSuccess} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<FormValues>({
|
||||||
|
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<AuthResponse>('auth/verify-code', { email, code })
|
||||||
|
tokenSet(data.token)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
{...register('email')}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Mail size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => requestCodeMutation.mutate()}
|
||||||
|
disabled={!email || requestCodeMutation.isPending}
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Отправить код
|
||||||
|
</Button>
|
||||||
|
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => verifyCodeMutation.mutate()}
|
||||||
|
disabled={!email || code.length !== 6 || verifyCodeMutation.isPending}
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{(requestCodeMutation.error || verifyCodeMutation.error) && (
|
||||||
|
<TextField
|
||||||
|
error
|
||||||
|
helperText={getApiErrorMessage(requestCodeMutation.error) || getApiErrorMessage(verifyCodeMutation.error)}
|
||||||
|
sx={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AuthForgotForm } from './ui/AuthForgotForm'
|
||||||
@@ -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<Step>('request')
|
||||||
|
|
||||||
|
const { register, watch } = useForm<FormValues>({
|
||||||
|
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 (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||||
|
{step === 'request'
|
||||||
|
? 'Введите email, на который будет отправлен код для сброса пароля'
|
||||||
|
: 'Введите код и новый пароль'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
{...register('email')}
|
||||||
|
disabled={step === 'reset'}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Mail size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{step === 'reset' && (
|
||||||
|
<>
|
||||||
|
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} fullWidth />
|
||||||
|
<TextField
|
||||||
|
label="Новый пароль"
|
||||||
|
type="password"
|
||||||
|
{...register('newPassword')}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Lock size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...register('passwordConfirm')}
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(passwordError)}
|
||||||
|
helperText={passwordError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'request' ? (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!email || forgotCodeMutation.isPending}
|
||||||
|
onClick={() => forgotCodeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Отправить код
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!code ||
|
||||||
|
code.length !== 6 ||
|
||||||
|
!newPassword ||
|
||||||
|
newPassword.length < 8 ||
|
||||||
|
Boolean(passwordError) ||
|
||||||
|
resetPasswordMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => resetPasswordMutation.mutate()}
|
||||||
|
>
|
||||||
|
Сменить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="text" size="small" onClick={onBack}>
|
||||||
|
Назад к входу
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(forgotCodeMutation.error || resetPasswordMutation.error) && (
|
||||||
|
<TextField
|
||||||
|
error
|
||||||
|
helperText={getApiErrorMessage(forgotCodeMutation.error) || getApiErrorMessage(resetPasswordMutation.error)}
|
||||||
|
sx={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AuthPasswordForm } from './ui/AuthPasswordForm'
|
||||||
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthPasswordForm isRegister={isRegister} onSuccess={onSuccess} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<FormValues>({
|
||||||
|
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<AuthResponse>('auth/login', { email, password })
|
||||||
|
tokenSet(data.token)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
})
|
||||||
|
tokenSet(data.token)
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: !isRegister ? 'primary.main' : 'text.secondary',
|
||||||
|
borderBottom: !isRegister ? 2 : 0,
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
borderRadius: 0,
|
||||||
|
pb: 0.5,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Вход
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: isRegister ? 'primary.main' : 'text.secondary',
|
||||||
|
borderBottom: isRegister ? 2 : 0,
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
borderRadius: 0,
|
||||||
|
pb: 0.5,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Регистрация
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
{...register('email')}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Mail size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<TextField
|
||||||
|
label="Имя (необязательно)"
|
||||||
|
{...register('displayName')}
|
||||||
|
fullWidth
|
||||||
|
helperText="Если не указать, будет использована часть email до @"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
{...register('password')}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Lock size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...register('passwordConfirm')}
|
||||||
|
fullWidth
|
||||||
|
error={Boolean(passwordError)}
|
||||||
|
helperText={passwordError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRegister ? (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={
|
||||||
|
!email ||
|
||||||
|
!password ||
|
||||||
|
password.length < 8 ||
|
||||||
|
(isRegister && password !== passwordConfirm) ||
|
||||||
|
registerMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => registerMutation.mutate()}
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={!email || !password || loginMutation.isPending}
|
||||||
|
onClick={() => loginMutation.mutate()}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(loginMutation.error || registerMutation.error) && (
|
||||||
|
<TextField
|
||||||
|
error
|
||||||
|
helperText={getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error)}
|
||||||
|
sx={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,10 +44,16 @@ describe('AuthPage', () => {
|
|||||||
expect(loginBtn).toBeTruthy()
|
expect(loginBtn).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to register form', () => {
|
it('switches to code tab', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
fireEvent.click(screen.getByText('Регистрация'))
|
fireEvent.click(screen.getByText('Код'))
|
||||||
expect(screen.getByText('Зарегистрироваться')).toBeTruthy()
|
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', () => {
|
it('switches to code tab', () => {
|
||||||
|
|||||||
@@ -2,71 +2,29 @@ import { useEffect, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import InputAdornment from '@mui/material/InputAdornment'
|
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import { alpha, useTheme } from '@mui/material/styles'
|
import { alpha, useTheme } from '@mui/material/styles'
|
||||||
import TextField from '@mui/material/TextField'
|
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { useUnit } from 'effector-react'
|
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 { 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 { OAuthButtons } from '@/features/auth-oauth'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { AuthPasswordForm } from '@/features/auth-password'
|
||||||
import { $user, tokenSet } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
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<string, unknown>
|
|
||||||
const response = anyErr.response as Record<string, unknown> | undefined
|
|
||||||
const data = response?.data as Record<string, unknown> | undefined
|
|
||||||
const msg = data?.error
|
|
||||||
return typeof msg === 'string' ? msg : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||||
const [tab, setTab] = useState(0)
|
const [tab, setTab] = useState(0)
|
||||||
const [isRegister, setIsRegister] = useState(false)
|
|
||||||
const [showForgot, setShowForgot] = useState(false)
|
const [showForgot, setShowForgot] = useState(false)
|
||||||
const [forgotStep, setForgotStep] = useState(0)
|
|
||||||
const [forgotEmail, setForgotEmail] = useState('')
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useUnit($user)
|
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(() => {
|
useEffect(() => {
|
||||||
if (user) navigate('/', { replace: true })
|
if (user) navigate('/', { replace: true })
|
||||||
}, [navigate, user])
|
}, [navigate, user])
|
||||||
@@ -74,79 +32,47 @@ export function AuthPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const err = searchParams.get('oauthError')
|
const err = searchParams.get('oauthError')
|
||||||
if (!err) return
|
if (!err) return
|
||||||
setOauthError(err)
|
const timeoutId = setTimeout(() => {
|
||||||
setSearchParams({}, { replace: true })
|
setOauthError(err)
|
||||||
}, [searchParams, setSearchParams])
|
setSearchParams({}, { replace: true })
|
||||||
|
}, 0)
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
if (showForgot) {
|
||||||
mutationFn: async () => {
|
return (
|
||||||
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
|
<Box
|
||||||
tokenSet(data.token)
|
sx={{
|
||||||
navigate('/', { replace: true })
|
display: 'flex',
|
||||||
},
|
alignItems: 'start',
|
||||||
})
|
justifyContent: 'center',
|
||||||
|
minHeight: 'calc(100vh - 64px)',
|
||||||
|
px: 2,
|
||||||
|
background: `radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||||
|
<BearLogo sx={{ fontSize: 72 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||||
mutationFn: async () => {
|
Восстановление пароля
|
||||||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
</Typography>
|
||||||
email,
|
|
||||||
password,
|
|
||||||
displayName: watch('displayName') || undefined,
|
|
||||||
})
|
|
||||||
tokenSet(data.token)
|
|
||||||
navigate('/', { replace: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const requestCode = useMutation({
|
<Paper
|
||||||
mutationFn: async () => {
|
sx={{
|
||||||
await apiClient.post('auth/request-code', { email })
|
p: 4,
|
||||||
},
|
borderRadius: 3,
|
||||||
onSuccess: () => setMessage('Код отправлен. Проверьте почту.'),
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
})
|
}}
|
||||||
|
>
|
||||||
const verifyCode = useMutation({
|
<AuthForgotForm onBack={() => setShowForgot(false)} />
|
||||||
mutationFn: async () => {
|
</Paper>
|
||||||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
</Box>
|
||||||
tokenSet(data.token)
|
</Box>
|
||||||
navigate('/', { replace: true })
|
)
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const forgotCode = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await apiClient.post('auth/forgot-password', { email: forgotEmail })
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setForgotStep(1)
|
|
||||||
setMessage('Код отправлен на почту')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetPassword = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await apiClient.post('auth/reset-password', {
|
|
||||||
email: forgotEmail,
|
|
||||||
code,
|
|
||||||
newPassword: password,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setShowForgot(false)
|
|
||||||
setForgotStep(0)
|
|
||||||
setMessage('Пароль изменён. Войдите с новым паролем.')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const errMsg =
|
|
||||||
getApiErrorMessage(loginMutation.error) ||
|
|
||||||
getApiErrorMessage(registerMutation.error) ||
|
|
||||||
getApiErrorMessage(requestCode.error) ||
|
|
||||||
getApiErrorMessage(verifyCode.error) ||
|
|
||||||
getApiErrorMessage(forgotCode.error) ||
|
|
||||||
getApiErrorMessage(resetPassword.error)
|
|
||||||
|
|
||||||
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -205,16 +131,9 @@ export function AuthPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{(errMsg || oauthError) && (
|
{oauthError && (
|
||||||
<Alert
|
<Alert severity="error" variant="outlined" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
|
||||||
severity="error"
|
{oauthError}
|
||||||
variant="outlined"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
onClose={() => {
|
|
||||||
setOauthError(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{errMsg || oauthError}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{message && (
|
{message && (
|
||||||
@@ -223,250 +142,14 @@ export function AuthPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 0 && (
|
{tab === 0 && <AuthPasswordForm isRegister={false} onSuccess={() => navigate('/', { replace: true })} />}
|
||||||
<Stack spacing={2}>
|
{tab === 1 && <AuthCodeForm onSuccess={() => navigate('/', { replace: true })} />}
|
||||||
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
color: !isRegister ? 'primary.main' : 'text.secondary',
|
|
||||||
borderBottom: !isRegister ? 2 : 0,
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
borderRadius: 0,
|
|
||||||
pb: 0.5,
|
|
||||||
textTransform: 'none',
|
|
||||||
}}
|
|
||||||
onClick={() => setIsRegister(false)}
|
|
||||||
>
|
|
||||||
Вход
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
color: isRegister ? 'primary.main' : 'text.secondary',
|
|
||||||
borderBottom: isRegister ? 2 : 0,
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
borderRadius: 0,
|
|
||||||
pb: 0.5,
|
|
||||||
textTransform: 'none',
|
|
||||||
}}
|
|
||||||
onClick={() => setIsRegister(true)}
|
|
||||||
>
|
|
||||||
Регистрация
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<TextField
|
<Box sx={{ mt: 2, mb: 1, display: 'flex', justifyContent: 'center' }}>
|
||||||
label="Email"
|
<Button variant="text" size="small" onClick={() => setShowForgot(true)}>
|
||||||
{...register('email')}
|
Забыли пароль?
|
||||||
fullWidth
|
</Button>
|
||||||
slotProps={{
|
</Box>
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Mail size={18} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isRegister && (
|
|
||||||
<TextField
|
|
||||||
label="Имя (необязательно)"
|
|
||||||
{...register('displayName')}
|
|
||||||
fullWidth
|
|
||||||
helperText="Если не указать, будет использована часть email до @"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Пароль"
|
|
||||||
type="password"
|
|
||||||
{...register('password')}
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Lock size={18} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isRegister && (
|
|
||||||
<TextField
|
|
||||||
label="Подтверждение пароля"
|
|
||||||
type="password"
|
|
||||||
{...register('passwordConfirm')}
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(passwordError)}
|
|
||||||
helperText={passwordError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRegister ? (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="large"
|
|
||||||
disabled={
|
|
||||||
!email ||
|
|
||||||
!password ||
|
|
||||||
password.length < 8 ||
|
|
||||||
(isRegister && password !== passwordConfirm) ||
|
|
||||||
registerMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={() => registerMutation.mutate()}
|
|
||||||
>
|
|
||||||
Зарегистрироваться
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="large"
|
|
||||||
disabled={!email || !password || loginMutation.isPending}
|
|
||||||
onClick={() => loginMutation.mutate()}
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isRegister && !showForgot && (
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
sx={{ textTransform: 'none', alignSelf: 'center', color: 'text.secondary' }}
|
|
||||||
onClick={() => {
|
|
||||||
setShowForgot(true)
|
|
||||||
setForgotStep(0)
|
|
||||||
setForgotEmail(email)
|
|
||||||
setMessage(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Забыли пароль?
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showForgot && (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Email"
|
|
||||||
value={forgotEmail}
|
|
||||||
onChange={(e) => setForgotEmail(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Mail size={18} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{forgotStep === 1 && (
|
|
||||||
<>
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Код (6 цифр)"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => {
|
|
||||||
register('code').onChange(e)
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => forgotCode.mutate()}
|
|
||||||
disabled={!forgotEmail || forgotCode.isPending}
|
|
||||||
sx={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
Отправить ещё раз
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<TextField label="Новый пароль" type="password" {...register('password')} fullWidth />
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={
|
|
||||||
!code || code.length !== 6 || !password || password.length < 8 || resetPassword.isPending
|
|
||||||
}
|
|
||||||
onClick={() => resetPassword.mutate()}
|
|
||||||
>
|
|
||||||
Сменить пароль
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{forgotStep === 0 && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={!forgotEmail || forgotCode.isPending}
|
|
||||||
onClick={() => forgotCode.mutate()}
|
|
||||||
>
|
|
||||||
Отправить код
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
sx={{ textTransform: 'none', alignSelf: 'center' }}
|
|
||||||
onClick={() => {
|
|
||||||
setShowForgot(false)
|
|
||||||
setForgotStep(0)
|
|
||||||
setMessage(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Назад к входу
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 1 && (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Email"
|
|
||||||
{...register('email')}
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Mail size={18} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => requestCode.mutate()}
|
|
||||||
disabled={!email || requestCode.isPending}
|
|
||||||
sx={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
Отправить код
|
|
||||||
</Button>
|
|
||||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => verifyCode.mutate()}
|
|
||||||
disabled={!email || code.length !== 6 || verifyCode.isPending}
|
|
||||||
sx={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ mt: 3, mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ mt: 3, mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Box sx={{ flex: 1, borderBottom: `1px solid ${theme.palette.divider}` }} />
|
<Box sx={{ flex: 1, borderBottom: `1px solid ${theme.palette.divider}` }} />
|
||||||
|
|||||||
@@ -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<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
|
||||||
|
|
||||||
|
export function AuthMethodsSection() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
|
||||||
|
const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
|
||||||
|
const [showSetPassword, setShowSetPassword] = useState(false)
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(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 (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Методы входа
|
||||||
|
</Typography>
|
||||||
|
{fetchError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{fetchError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{authMethods.map((m) => (
|
||||||
|
<Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||||
|
<Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
|
||||||
|
<Chip label={m.active ? 'Привязан' : 'Не привязан'} color={m.active ? 'success' : 'default'} size="small" />
|
||||||
|
{m.active && m.type !== 'password' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
disabled={linkedCount() <= 1}
|
||||||
|
onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{m.active && m.type === 'password' && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setShowChangePassword(true)}>
|
||||||
|
Сменить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!m.active && m.type === 'password' && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
|
||||||
|
Установить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!m.active && m.type !== 'password' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={`/api/auth/oauth/${m.type}/link?token=${localStorage.getItem('craftshop_auth_token') || ''}`}
|
||||||
|
>
|
||||||
|
Привязать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{showSetPassword && (
|
||||||
|
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...passwordForm.register('passwordConfirm')}
|
||||||
|
fullWidth
|
||||||
|
error={
|
||||||
|
Boolean(passwordForm.watch('passwordConfirm')) &&
|
||||||
|
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
passwordForm.watch('passwordConfirm') &&
|
||||||
|
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
||||||
|
? 'Пароли не совпадают'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!passwordForm.watch('password') ||
|
||||||
|
passwordForm.watch('password').length < 8 ||
|
||||||
|
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
|
||||||
|
setPasswordMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => setShowSetPassword(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showChangePassword && (
|
||||||
|
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<TextField label="Текущий пароль" type="password" {...changePasswordForm.register('oldPassword')} fullWidth />
|
||||||
|
<TextField label="Новый пароль" type="password" {...changePasswordForm.register('newPassword')} fullWidth />
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...changePasswordForm.register('confirmPassword')}
|
||||||
|
fullWidth
|
||||||
|
error={
|
||||||
|
Boolean(changePasswordForm.watch('confirmPassword')) &&
|
||||||
|
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
changePasswordForm.watch('confirmPassword') &&
|
||||||
|
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
||||||
|
? 'Пароли не совпадают'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!changePasswordForm.watch('oldPassword') ||
|
||||||
|
!changePasswordForm.watch('newPassword') ||
|
||||||
|
changePasswordForm.watch('newPassword').length < 8 ||
|
||||||
|
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword') ||
|
||||||
|
changePasswordMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
changePasswordMutation.mutate({
|
||||||
|
oldPassword: changePasswordForm.getValues('oldPassword'),
|
||||||
|
newPassword: changePasswordForm.getValues('newPassword'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => setShowChangePassword(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string | null>(null)
|
||||||
|
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
||||||
|
|
||||||
|
const hasUnsavedPreview = previewSrc !== null
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Аватар
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<UserAvatar
|
||||||
|
userId={user.id}
|
||||||
|
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
|
||||||
|
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
|
||||||
|
size={80}
|
||||||
|
sx={{
|
||||||
|
border: 2,
|
||||||
|
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{hasUnsavedPreview && (
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<UserAvatar
|
||||||
|
userId={user.id}
|
||||||
|
avatarUrl={user.avatar}
|
||||||
|
avatarStyle={user.avatarStyle}
|
||||||
|
size={80}
|
||||||
|
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
Текущий
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||||
|
<InputLabel>Стиль</InputLabel>
|
||||||
|
<Select
|
||||||
|
key={user?.avatarStyle || 'none'}
|
||||||
|
value={selectedStyle}
|
||||||
|
label="Стиль"
|
||||||
|
onChange={(e) => setSelectedStyle(e.target.value)}
|
||||||
|
>
|
||||||
|
{AVATAR_STYLES.map((s) => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
const seed = `${user.id}_${Date.now()}`
|
||||||
|
const styleDef = getStyleById(selectedStyle)
|
||||||
|
const avatar = createAvatar(styleDef.style, { seed })
|
||||||
|
setPreviewSrc(avatar.toDataUri())
|
||||||
|
setPreviewStyle(selectedStyle)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сгенерировать
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{hasUnsavedPreview && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={pendingProfile}
|
||||||
|
onClick={() => {
|
||||||
|
updateProfileFx({
|
||||||
|
displayName: user.displayName?.trim() || null,
|
||||||
|
avatar: previewSrc,
|
||||||
|
avatarStyle: previewStyle,
|
||||||
|
})
|
||||||
|
setPreviewSrc(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => setPreviewSrc(null)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Профиль
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Имя или ник"
|
||||||
|
helperText="До 40 символов"
|
||||||
|
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||||
|
{...profileForm.register('displayName')}
|
||||||
|
/>
|
||||||
|
{updateProfileError && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{updateProfileError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={pendingProfile}
|
||||||
|
onClick={() => {
|
||||||
|
const raw = profileForm.getValues('displayName')
|
||||||
|
const name = raw.trim()
|
||||||
|
updateProfileFx({ displayName: name.length ? name : null })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,113 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
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 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 Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { createAvatar } from '@dicebear/core'
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
|
import { AuthMethodsSection } from './AuthMethodsSection'
|
||||||
import {
|
import { AvatarSection } from './AvatarSection'
|
||||||
$updateProfileError,
|
import { ProfileSection } from './ProfileSection'
|
||||||
$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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const user = useUnit($user)
|
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<string | null>(null)
|
|
||||||
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
|
||||||
|
|
||||||
const hasUnsavedPreview = previewSrc !== null
|
|
||||||
|
|
||||||
const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
|
|
||||||
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<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||||
@@ -122,271 +25,14 @@ export function SettingsPage() {
|
|||||||
Текущая почта: <b>{user.email}</b>
|
Текущая почта: <b>{user.email}</b>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{profileErrorMsg && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{profileErrorMsg}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||||||
<Box>
|
<ProfileSection />
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Профиль
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Имя или ник"
|
|
||||||
helperText="До 40 символов"
|
|
||||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
|
||||||
{...profileForm.register('displayName')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={pendingProfile}
|
|
||||||
onClick={() => {
|
|
||||||
const raw = profileForm.getValues('displayName')
|
|
||||||
const name = raw.trim()
|
|
||||||
updateProfileFx({ displayName: name.length ? name : null })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<AvatarSection />
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Аватар
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<UserAvatar
|
|
||||||
userId={user.id}
|
|
||||||
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
|
|
||||||
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
|
|
||||||
size={80}
|
|
||||||
sx={{
|
|
||||||
border: 2,
|
|
||||||
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{hasUnsavedPreview && (
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<UserAvatar
|
|
||||||
userId={user.id}
|
|
||||||
avatarUrl={user.avatar}
|
|
||||||
avatarStyle={user.avatarStyle}
|
|
||||||
size={80}
|
|
||||||
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
Текущий
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Стиль</InputLabel>
|
|
||||||
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
|
|
||||||
{AVATAR_STYLES.map((s) => (
|
|
||||||
<MenuItem key={s.id} value={s.id}>
|
|
||||||
{s.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => {
|
|
||||||
const seed = `${user.id}_${Date.now()}`
|
|
||||||
const styleDef = getStyleById(selectedStyle)
|
|
||||||
const avatar = createAvatar(styleDef.style, { seed })
|
|
||||||
setPreviewSrc(avatar.toDataUri())
|
|
||||||
setPreviewStyle(selectedStyle)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сгенерировать
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{hasUnsavedPreview && (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={pendingProfile}
|
|
||||||
onClick={() => {
|
|
||||||
updateProfileFx({
|
|
||||||
displayName: user.displayName?.trim() || null,
|
|
||||||
avatar: previewSrc,
|
|
||||||
avatarStyle: previewStyle,
|
|
||||||
})
|
|
||||||
setPreviewSrc(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
<Button variant="text" onClick={() => setPreviewSrc(null)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{!user.isAdmin && (
|
{!user.isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box>
|
<AuthMethodsSection />
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Методы входа
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{authMethods.map((m) => (
|
|
||||||
<Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
|
||||||
<Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
|
|
||||||
<Chip
|
|
||||||
label={m.active ? 'Привязан' : 'Не привязан'}
|
|
||||||
color={m.active ? 'success' : 'default'}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
{m.active && m.type !== 'password' && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
disabled={linkedCount() <= 1}
|
|
||||||
onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
|
|
||||||
>
|
|
||||||
Отвязать
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{m.active && m.type === 'password' && (
|
|
||||||
<Button size="small" variant="outlined" onClick={() => setShowChangePassword(true)}>
|
|
||||||
Сменить пароль
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!m.active && m.type === 'password' && (
|
|
||||||
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
|
|
||||||
Установить пароль
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!m.active && m.type !== 'password' && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
component="a"
|
|
||||||
href={`/api/auth/oauth/${m.type}/link?token=${localStorage.getItem('craftshop_auth_token') || ''}`}
|
|
||||||
>
|
|
||||||
Привязать
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{showSetPassword && (
|
|
||||||
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
||||||
<TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
|
|
||||||
<TextField
|
|
||||||
label="Подтверждение пароля"
|
|
||||||
type="password"
|
|
||||||
{...passwordForm.register('passwordConfirm')}
|
|
||||||
fullWidth
|
|
||||||
error={
|
|
||||||
Boolean(passwordForm.watch('passwordConfirm')) &&
|
|
||||||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
|
||||||
}
|
|
||||||
helperText={
|
|
||||||
passwordForm.watch('passwordConfirm') &&
|
|
||||||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
|
||||||
? 'Пароли не совпадают'
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={
|
|
||||||
!passwordForm.watch('password') ||
|
|
||||||
passwordForm.watch('password').length < 8 ||
|
|
||||||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
|
|
||||||
setPasswordMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
<Button variant="text" onClick={() => setShowSetPassword(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showChangePassword && (
|
|
||||||
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
||||||
<TextField
|
|
||||||
label="Текущий пароль"
|
|
||||||
type="password"
|
|
||||||
{...changePasswordForm.register('oldPassword')}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Новый пароль"
|
|
||||||
type="password"
|
|
||||||
{...changePasswordForm.register('newPassword')}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Подтверждение пароля"
|
|
||||||
type="password"
|
|
||||||
{...changePasswordForm.register('confirmPassword')}
|
|
||||||
fullWidth
|
|
||||||
error={
|
|
||||||
Boolean(changePasswordForm.watch('confirmPassword')) &&
|
|
||||||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
|
||||||
}
|
|
||||||
helperText={
|
|
||||||
changePasswordForm.watch('confirmPassword') &&
|
|
||||||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
|
||||||
? 'Пароли не совпадают'
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={
|
|
||||||
!changePasswordForm.watch('oldPassword') ||
|
|
||||||
!changePasswordForm.watch('newPassword') ||
|
|
||||||
changePasswordForm.watch('newPassword').length < 8 ||
|
|
||||||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword') ||
|
|
||||||
changePasswordMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
changePasswordMutation.mutate({
|
|
||||||
oldPassword: changePasswordForm.getValues('oldPassword'),
|
|
||||||
newPassword: changePasswordForm.getValues('newPassword'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
<Button variant="text" onClick={() => setShowChangePassword(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<AuthMethodsSection />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthMethodsSection', () => {
|
||||||
|
it('renders auth methods section', async () => {
|
||||||
|
renderSection()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Методы входа')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,<svg></svg>',
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AvatarSection', () => {
|
||||||
|
it('renders avatar section', async () => {
|
||||||
|
render(<AvatarSection />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Аватар')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(<ProfileSection />)
|
||||||
|
expect(screen.getByText('Профиль')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Имя или ник')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Сохранить' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function getApiErrorMessage(err: unknown): string | null {
|
||||||
|
if (!err || typeof err !== 'object') return null
|
||||||
|
const anyErr = err as Record<string, unknown>
|
||||||
|
const response = anyErr.response as Record<string, unknown> | undefined
|
||||||
|
const data = response?.data as Record<string, unknown> | undefined
|
||||||
|
const msg = data?.error
|
||||||
|
return typeof msg === 'string' ? msg : null
|
||||||
|
}
|
||||||
@@ -100,6 +100,10 @@ export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => {
|
|||||||
await apiClient.delete(`me/oauth/${provider}`)
|
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 -----
|
// ----- Error stores -----
|
||||||
|
|
||||||
export const $updateProfileError = createErrorStore(updateProfileFx).$error
|
export const $updateProfileError = createErrorStore(updateProfileFx).$error
|
||||||
|
|||||||
Binary file not shown.
@@ -90,7 +90,6 @@ fastify.decorate('eventBus', eventBus)
|
|||||||
fastify.decorate('notificationQueue', notificationQueue)
|
fastify.decorate('notificationQueue', notificationQueue)
|
||||||
|
|
||||||
registerAuth(fastify)
|
registerAuth(fastify)
|
||||||
await registerAuthRoutes(fastify)
|
|
||||||
await registerUserAddressRoutes(fastify)
|
await registerUserAddressRoutes(fastify)
|
||||||
await registerUserCartRoutes(fastify)
|
await registerUserCartRoutes(fastify)
|
||||||
await registerUserMessageRoutes(fastify)
|
await registerUserMessageRoutes(fastify)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import jwt from '@fastify/jwt'
|
|||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
import { registerAuthRoutes } from '../auth.js'
|
import { registerAuthSessionRoutes } from '../auth-session.js'
|
||||||
|
|
||||||
const JWT_SECRET = 'test-secret'
|
const JWT_SECRET = 'test-secret'
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ async function buildApp() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
app.decorate('eventBus', { emit: () => {} })
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
await registerAuthRoutes(app)
|
await registerAuthSessionRoutes(app)
|
||||||
await app.ready()
|
await app.ready()
|
||||||
return app
|
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)
|
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('последний метод')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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('последний метод')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import jwt from '@fastify/jwt'
|
import jwt from '@fastify/jwt'
|
||||||
import Fastify from 'fastify'
|
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 { prisma } from '../../lib/prisma.js'
|
||||||
import { registerAuthRoutes } from '../auth.js'
|
import { registerAuthPasswordRoutes } from '../auth-password.js'
|
||||||
|
|
||||||
const JWT_SECRET = 'test-secret'
|
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() {
|
async function buildApp() {
|
||||||
const app = Fastify({ logger: false })
|
const app = Fastify({ logger: false })
|
||||||
@@ -19,129 +17,109 @@ async function buildApp() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
app.decorate('eventBus', { emit: () => {} })
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
await registerAuthRoutes(app)
|
await registerAuthPasswordRoutes(app)
|
||||||
await app.ready()
|
await app.ready()
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('POST /api/auth/register', () => {
|
function signToken(app, userId, email) {
|
||||||
let app
|
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 () => {
|
beforeAll(async () => {
|
||||||
app = await buildApp()
|
app = await buildApp()
|
||||||
})
|
})
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
afterEach(async () => {
|
|
||||||
await prisma.authCode.deleteMany({ where: { email: TEST_EMAIL } })
|
beforeEach(async () => {
|
||||||
await prisma.notificationPreference.deleteMany({ where: { user: { email: TEST_EMAIL } } })
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
await prisma.user.deleteMany({ where: { email: TEST_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({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/register',
|
url: '/api/me/password',
|
||||||
payload: { email: TEST_EMAIL, password: 'Test123!@' },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { password: 'Test123!@' },
|
||||||
})
|
})
|
||||||
expect(res.statusCode).toBe(201)
|
expect(res.statusCode).toBe(200)
|
||||||
const body = JSON.parse(res.body)
|
|
||||||
expect(body.token).toBeTruthy()
|
const u = await prisma.user.findUnique({ where: { id: user.id } })
|
||||||
expect(body.user.email).toBe(TEST_EMAIL)
|
expect(u.passwordHash).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects duplicate email', async () => {
|
it('rejects if password already set', async () => {
|
||||||
await app.inject({
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } })
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: TEST_EMAIL, password: 'Test123!@' },
|
|
||||||
})
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/register',
|
url: '/api/me/password',
|
||||||
payload: { email: TEST_EMAIL, password: 'Test123!@' },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { password: 'Test123!@' },
|
||||||
})
|
})
|
||||||
expect(res.statusCode).toBe(409)
|
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', () => {
|
describe('POST /api/me/change-password', () => {
|
||||||
let app
|
let app, user, token
|
||||||
|
const email = `test-change-pw-${Date.now()}@example.com`
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await buildApp()
|
app = await buildApp()
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/auth/register',
|
|
||||||
payload: { email: LOGIN_EMAIL, password: 'Test123!@' },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.authCode.deleteMany({ where: { email: LOGIN_EMAIL } })
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
await prisma.notificationPreference.deleteMany({ where: { user: { email: LOGIN_EMAIL } } })
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
await prisma.oAuthAccount.deleteMany({ where: { user: { email: LOGIN_EMAIL } } })
|
|
||||||
await prisma.user.deleteMany({ where: { email: LOGIN_EMAIL } })
|
|
||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('logs in with correct password', async () => {
|
beforeEach(async () => {
|
||||||
const res = await app.inject({
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
method: 'POST',
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
url: '/api/auth/login',
|
user = await createUser(email)
|
||||||
payload: { email: LOGIN_EMAIL, password: 'Test123!@' },
|
token = signToken(app, user.id, email)
|
||||||
headers: { 'x-forwarded-for': '1.1.1.1' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(JSON.parse(res.body).token).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/login',
|
url: '/api/me/change-password',
|
||||||
payload: { email: LOGIN_EMAIL, password: 'Wrong!!1!' },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
headers: { 'x-forwarded-for': '2.2.2.2' },
|
payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
|
||||||
})
|
})
|
||||||
expect(res.statusCode).toBe(401)
|
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({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/login',
|
url: '/api/me/change-password',
|
||||||
payload: { email: 'nobody@nowhere.test', password: 'Test123!@' },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
headers: { 'x-forwarded-for': '3.3.3.3' },
|
payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
|
||||||
})
|
})
|
||||||
expect(res.statusCode).toBe(401)
|
expect(res.statusCode).toBe(400)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,10 @@ import { registerAdminUserRoutes } from './api/admin-users.js'
|
|||||||
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||||
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||||
import { registerPublicReviewRoutes } from './api/public-reviews.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) {
|
export async function registerApiRoutes(fastify) {
|
||||||
fastify.decorate('slugify', slugify)
|
fastify.decorate('slugify', slugify)
|
||||||
@@ -28,4 +32,9 @@ export async function registerApiRoutes(fastify) {
|
|||||||
await registerAdminUserRoutes(fastify)
|
await registerAdminUserRoutes(fastify)
|
||||||
await registerAdminNotificationRoutes(fastify)
|
await registerAdminNotificationRoutes(fastify)
|
||||||
await registerAdminProfileRoutes(fastify)
|
await registerAdminProfileRoutes(fastify)
|
||||||
|
|
||||||
|
await registerAuthRoutes(fastify)
|
||||||
|
await registerAuthSessionRoutes(fastify)
|
||||||
|
await registerAuthPasswordRoutes(fastify)
|
||||||
|
await registerAuthOAuthRoutes(fastify)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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') },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
-102
@@ -11,7 +11,7 @@ import {
|
|||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
import { checkLoginRateLimit } from '../lib/rate-limit.js'
|
import { checkLoginRateLimit } from '../lib/rate-limit.js'
|
||||||
|
|
||||||
function mapUserForClient(user) {
|
export function mapUserForClient(user) {
|
||||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||||
const userEmail = normalizeEmail(user.email)
|
const userEmail = normalizeEmail(user.email)
|
||||||
return {
|
return {
|
||||||
@@ -171,107 +171,6 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
return { ok: true }
|
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) => {
|
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const nameRaw = request.body?.displayName
|
const nameRaw = request.body?.displayName
|
||||||
|
|||||||
Reference in New Issue
Block a user