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 { useUnit } from 'effector-react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { AuthCodeForm } from '@/features/auth-code'
|
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 { AuthPasswordForm } from '@/features/auth-password'
|
import { AuthPasswordForm } from '@/features/auth-password'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
@@ -19,6 +20,7 @@ export function AuthPage() {
|
|||||||
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 [showForgot, setShowForgot] = useState(false)
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
@@ -27,10 +29,46 @@ export function AuthPage() {
|
|||||||
if (user) navigate('/', { replace: true })
|
if (user) navigate('/', { replace: true })
|
||||||
}, [navigate, user])
|
}, [navigate, user])
|
||||||
|
|
||||||
const oauthErrorParam = searchParams.get('oauthError')
|
useEffect(() => {
|
||||||
if (oauthErrorParam) {
|
const err = searchParams.get('oauthError')
|
||||||
setOauthError(oauthErrorParam)
|
if (!err) return
|
||||||
|
setOauthError(err)
|
||||||
setSearchParams({}, { replace: true })
|
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 (
|
return (
|
||||||
@@ -104,6 +142,12 @@ export function AuthPage() {
|
|||||||
{tab === 0 && <AuthPasswordForm isRegister={false} onSuccess={() => navigate('/', { replace: true })} />}
|
{tab === 0 && <AuthPasswordForm isRegister={false} onSuccess={() => navigate('/', { replace: true })} />}
|
||||||
{tab === 1 && <AuthCodeForm 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={{ 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}` }} />
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ px: 1, whiteSpace: 'nowrap' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ px: 1, whiteSpace: 'nowrap' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user