refactor(auth): extract AuthPasswordForm and AuthCodeForm to features
- Create auth-password feature with login/register form - Create auth-code feature with email+code verification form - Extract getApiErrorMessage to shared lib - Simplify AuthPage to pure UI composer with tabs - Update tests for new component structure - All 40 tests passing
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 { 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()
|
||||
})
|
||||
|
||||
it('switches to register form', () => {
|
||||
it('switches to code tab', () => {
|
||||
renderPage()
|
||||
fireEvent.click(screen.getByText('Регистрация'))
|
||||
expect(screen.getByText('Зарегистрироваться')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('Код'))
|
||||
expect(screen.getByText('Отправить код')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows auth password form with login by default', () => {
|
||||
renderPage()
|
||||
expect(screen.getByText('Вход')).toBeTruthy()
|
||||
expect(screen.getByText('Регистрация')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('switches to code tab', () => {
|
||||
|
||||
@@ -2,151 +2,36 @@ import { useEffect, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import { alpha, useTheme } from '@mui/material/styles'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Lock, Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { AuthCodeForm } from '@/features/auth-code'
|
||||
import { OAuthButtons } from '@/features/auth-oauth'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { $user, tokenSet } from '@/shared/model/auth'
|
||||
import { AuthPasswordForm } from '@/features/auth-password'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
|
||||
type AuthResponse = {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName?: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: unknown): string | null {
|
||||
if (!err || typeof err !== 'object') return null
|
||||
const anyErr = err as Record<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() {
|
||||
const theme = useTheme()
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState(0)
|
||||
const [isRegister, setIsRegister] = useState(false)
|
||||
const [showForgot, setShowForgot] = useState(false)
|
||||
const [forgotStep, setForgotStep] = useState(0)
|
||||
const [forgotEmail, setForgotEmail] = useState('')
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const user = useUnit($user)
|
||||
|
||||
const { register, watch } = useForm<{
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirm: string
|
||||
displayName: string
|
||||
code: string
|
||||
}>({
|
||||
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const password = watch('password')
|
||||
const passwordConfirm = watch('passwordConfirm')
|
||||
const code = watch('code')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate('/', { replace: true })
|
||||
}, [navigate, user])
|
||||
|
||||
useEffect(() => {
|
||||
const err = searchParams.get('oauthError')
|
||||
if (!err) return
|
||||
setOauthError(err)
|
||||
const oauthErrorParam = searchParams.get('oauthError')
|
||||
if (oauthErrorParam) {
|
||||
setOauthError(oauthErrorParam)
|
||||
setSearchParams({}, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
|
||||
tokenSet(data.token)
|
||||
navigate('/', { replace: true })
|
||||
},
|
||||
})
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
||||
email,
|
||||
password,
|
||||
displayName: watch('displayName') || undefined,
|
||||
})
|
||||
tokenSet(data.token)
|
||||
navigate('/', { replace: true })
|
||||
},
|
||||
})
|
||||
|
||||
const requestCode = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/request-code', { email })
|
||||
},
|
||||
onSuccess: () => setMessage('Код отправлен. Проверьте почту.'),
|
||||
})
|
||||
|
||||
const verifyCode = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
||||
tokenSet(data.token)
|
||||
navigate('/', { replace: true })
|
||||
},
|
||||
})
|
||||
|
||||
const forgotCode = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/forgot-password', { email: forgotEmail })
|
||||
},
|
||||
onSuccess: () => {
|
||||
setForgotStep(1)
|
||||
setMessage('Код отправлен на почту')
|
||||
},
|
||||
})
|
||||
|
||||
const resetPassword = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/reset-password', {
|
||||
email: forgotEmail,
|
||||
code,
|
||||
newPassword: password,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowForgot(false)
|
||||
setForgotStep(0)
|
||||
setMessage('Пароль изменён. Войдите с новым паролем.')
|
||||
},
|
||||
})
|
||||
|
||||
const errMsg =
|
||||
getApiErrorMessage(loginMutation.error) ||
|
||||
getApiErrorMessage(registerMutation.error) ||
|
||||
getApiErrorMessage(requestCode.error) ||
|
||||
getApiErrorMessage(verifyCode.error) ||
|
||||
getApiErrorMessage(forgotCode.error) ||
|
||||
getApiErrorMessage(resetPassword.error)
|
||||
|
||||
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -205,16 +90,9 @@ export function AuthPage() {
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{(errMsg || oauthError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => {
|
||||
setOauthError(null)
|
||||
}}
|
||||
>
|
||||
{errMsg || oauthError}
|
||||
{oauthError && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
|
||||
{oauthError}
|
||||
</Alert>
|
||||
)}
|
||||
{message && (
|
||||
@@ -223,250 +101,8 @@ export function AuthPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tab === 0 && (
|
||||
<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',
|
||||
}}
|
||||
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
|
||||
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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
{tab === 0 && <AuthPasswordForm isRegister={false} onSuccess={() => navigate('/', { replace: true })} />}
|
||||
{tab === 1 && <AuthCodeForm onSuccess={() => navigate('/', { replace: true })} />}
|
||||
|
||||
<Box sx={{ mt: 3, mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, borderBottom: `1px solid ${theme.palette.divider}` }} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user