feat: add password change and reset via email code

This commit is contained in:
Kirill
2026-05-22 14:12:29 +05:00
parent 22282c5f4e
commit ad43ff98b6
4 changed files with 259 additions and 2 deletions
+125 -2
View File
@@ -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.
+57
View File
@@ -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