fix(auth): add forgot password flow and fix OAuth URL clearing
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import Typography from '@mui/material/Typography'
|
||||
import { useUnit } from 'effector-react'
|
||||
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 { AuthPasswordForm } from '@/features/auth-password'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
@@ -19,6 +20,7 @@ export function AuthPage() {
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState(0)
|
||||
const [showForgot, setShowForgot] = useState(false)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const user = useUnit($user)
|
||||
@@ -27,10 +29,46 @@ export function AuthPage() {
|
||||
if (user) navigate('/', { replace: true })
|
||||
}, [navigate, user])
|
||||
|
||||
const oauthErrorParam = searchParams.get('oauthError')
|
||||
if (oauthErrorParam) {
|
||||
setOauthError(oauthErrorParam)
|
||||
useEffect(() => {
|
||||
const err = searchParams.get('oauthError')
|
||||
if (!err) return
|
||||
setOauthError(err)
|
||||
setSearchParams({}, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
if (showForgot) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
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>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||
Восстановление пароля
|
||||
</Typography>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<AuthForgotForm onBack={() => setShowForgot(false)} />
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,6 +142,12 @@ export function AuthPage() {
|
||||
{tab === 0 && <AuthPasswordForm isRegister={false} onSuccess={() => navigate('/', { replace: true })} />}
|
||||
{tab === 1 && <AuthCodeForm onSuccess={() => navigate('/', { replace: true })} />}
|
||||
|
||||
<Box sx={{ mt: 2, mb: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button variant="text" size="small" onClick={() => setShowForgot(true)}>
|
||||
Забыли пароль?
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, borderBottom: `1px solid ${theme.palette.divider}` }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ px: 1, whiteSpace: 'nowrap' }}>
|
||||
|
||||
Reference in New Issue
Block a user