feat: add password change and reset via email code
This commit is contained in:
@@ -44,6 +44,9 @@ export function AuthPage() {
|
|||||||
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 [isRegister, setIsRegister] = 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)
|
||||||
@@ -99,7 +102,7 @@ export function AuthPage() {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await apiClient.post('auth/request-code', { email })
|
await apiClient.post('auth/request-code', { email })
|
||||||
},
|
},
|
||||||
onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'),
|
onSuccess: () => setMessage('Код отправлен. Проверьте почту.'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const verifyCode = useMutation({
|
const verifyCode = useMutation({
|
||||||
@@ -110,11 +113,38 @@ export function AuthPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 =
|
const errMsg =
|
||||||
getApiErrorMessage(loginMutation.error) ||
|
getApiErrorMessage(loginMutation.error) ||
|
||||||
getApiErrorMessage(registerMutation.error) ||
|
getApiErrorMessage(registerMutation.error) ||
|
||||||
getApiErrorMessage(requestCode.error) ||
|
getApiErrorMessage(requestCode.error) ||
|
||||||
getApiErrorMessage(verifyCode.error)
|
getApiErrorMessage(verifyCode.error) ||
|
||||||
|
getApiErrorMessage(forgotCode.error) ||
|
||||||
|
getApiErrorMessage(resetPassword.error)
|
||||||
|
|
||||||
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||||
|
|
||||||
@@ -304,6 +334,99 @@ export function AuthPage() {
|
|||||||
Войти
|
Войти
|
||||||
</Button>
|
</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>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type AuthMethod,
|
type AuthMethod,
|
||||||
} from '@/shared/model/auth'
|
} from '@/shared/model/auth'
|
||||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
function getApiErrorMessage(error: unknown): string | null {
|
function getApiErrorMessage(error: unknown): string | null {
|
||||||
@@ -101,6 +102,21 @@ export function SettingsPage() {
|
|||||||
onError: () => {},
|
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(() => {
|
const linkedCount = useCallback(() => {
|
||||||
return authMethods.filter((m) => m.active).length
|
return authMethods.filter((m) => m.active).length
|
||||||
}, [authMethods])
|
}, [authMethods])
|
||||||
@@ -271,6 +287,11 @@ export function SettingsPage() {
|
|||||||
Отвязать
|
Отвязать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{m.active && m.type === 'password' && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setShowChangePassword(true)}>
|
||||||
|
Сменить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!m.active && m.type === 'password' && (
|
{!m.active && m.type === 'password' && (
|
||||||
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
|
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
|
||||||
Установить пароль
|
Установить пароль
|
||||||
@@ -328,6 +349,62 @@ export function SettingsPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</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>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Binary file not shown.
@@ -138,6 +138,39 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
return { token, user: mapUserForClient(user) }
|
return { token, user: mapUserForClient(user) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/auth/forgot-password', async (request) => {
|
||||||
|
const email = normalizeEmail(request.body?.email)
|
||||||
|
if (!email || !email.includes('@')) return { ok: true }
|
||||||
|
|
||||||
|
if (isAdminEmail(email)) return { ok: true }
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (!user || !user.passwordHash) return { ok: true }
|
||||||
|
|
||||||
|
await issueEmailCode({ email, purpose: 'reset_password' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post('/api/auth/reset-password', async (request, reply) => {
|
||||||
|
const email = normalizeEmail(request.body?.email)
|
||||||
|
const code = String(request.body?.code || '').trim()
|
||||||
|
const newPassword = String(request.body?.newPassword || '')
|
||||||
|
|
||||||
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
|
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||||
|
|
||||||
|
const ok = await verifyEmailCode({ email, purpose: 'reset_password', code })
|
||||||
|
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||||
|
|
||||||
|
const passwordErr = validatePassword(newPassword)
|
||||||
|
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(newPassword)
|
||||||
|
await prisma.user.update({ where: { email }, data: { passwordHash } })
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
@@ -183,6 +216,30 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
return { ok: true }
|
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) => {
|
fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const provider = request.params?.provider
|
const provider = request.params?.provider
|
||||||
|
|||||||
Reference in New Issue
Block a user