fix(auth): add forgot password flow and fix OAuth URL clearing

This commit is contained in:
Kirill
2026-05-22 14:47:06 +05:00
parent 68bbbf8895
commit b1530ef705
3 changed files with 193 additions and 3 deletions
+1
View File
@@ -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>
)
}
+47 -3
View File
@@ -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' }}>