From ad43ff98b68ab36bb47108d0e5c7a804e2081bfd Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:12:29 +0500 Subject: [PATCH] feat: add password change and reset via email code --- client/src/pages/auth/ui/AuthPage.tsx | 127 +++++++++++++++++- .../src/pages/me/ui/sections/SettingsPage.tsx | 77 +++++++++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/auth.js | 57 ++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 36c87a6..985b653 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -44,6 +44,9 @@ export function AuthPage() { const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) const [isRegister, setIsRegister] = useState(false) + const [showForgot, setShowForgot] = useState(false) + const [forgotStep, setForgotStep] = useState(0) + const [forgotEmail, setForgotEmail] = useState('') const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const user = useUnit($user) @@ -99,7 +102,7 @@ export function AuthPage() { mutationFn: async () => { await apiClient.post('auth/request-code', { email }) }, - onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'), + onSuccess: () => setMessage('Код отправлен. Проверьте почту.'), }) 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 = getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error) || getApiErrorMessage(requestCode.error) || - getApiErrorMessage(verifyCode.error) + getApiErrorMessage(verifyCode.error) || + getApiErrorMessage(forgotCode.error) || + getApiErrorMessage(resetPassword.error) const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null @@ -304,6 +334,99 @@ export function AuthPage() { Войти )} + + {!isRegister && !showForgot && ( + + )} + + {showForgot && ( + <> + setForgotEmail(e.target.value)} + fullWidth + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {forgotStep === 1 && ( + <> + + { + register('code').onChange(e) + }} + sx={{ flex: 1 }} + /> + + + + + + )} + + {forgotStep === 0 && ( + + )} + + + + )} )} diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 943f542..4b62caa 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -30,6 +30,7 @@ import { type AuthMethod, } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' +import { apiClient } from '@/shared/api/client' import type { AxiosError } from 'axios' function getApiErrorMessage(error: unknown): string | null { @@ -101,6 +102,21 @@ export function SettingsPage() { 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(() => { return authMethods.filter((m) => m.active).length }, [authMethods]) @@ -271,6 +287,11 @@ export function SettingsPage() { Отвязать )} + {m.active && m.type === 'password' && ( + + )} {!m.active && m.type === 'password' && ( + + + + )} )} diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index c83aff765fa853729b0eea5bb0f24c39e259b017..cda19f98ac667ce705f33772b47bee629bf290d8 100644 GIT binary patch delta 2032 zcmaJ?T}&KR6rS0c5h(25C59RlN^Oy7D((F5?1P!LNC^#HNL!@Fre$V#_J3z)|7KWT zfER0HNNb4MM3a^$jrt;PU43axOq(XArue{%FKXMQF~nF5`p^gQ-dVPQ1J1+QY|eMj z`ObIGJ!f`t|Lo%atIvDD%KKf%p=lho5&FhLZ#kT#`c&5VYS)XT(p9houW zNqq;9-466%7}gipiv4D{Qr?1zbWqw3BfnfE{XWVoU?Ata?>pnYHfo>D z%z2-qC<=>Y&A6ebg(ywaQp%cUqhc;*Tj^BRRML__God&t4>1&2QBgEO3q~VBfsL>{ ztMXA^ROzV3X@bfqf*6dlfgl4ZXJ8(fu%-)OVg8RUoPySF zqrmqV_#3Q(U%<-O{x9&NuR3|~1m3!jpm3NE=BBDE8>@22RJn2tR?QWRNH(rBT9VD% z5V5{?*K?SZsTL#+1WgAZNJOAQG0hv&blCwts)g|T-~d8*l1HePvBB^g!{MQeW3Rt; zYA}3u6ElXln)vN%d*~xjxpti$9})6hxtajO)~t7b$~Q+nlt4bQ8x zsq!=nT?Puvnc0XXlr<~OupHv~CsDV161YLEf{tBd)cnLM%nVg>v95&(`dj2Gr(k9Q z3CM}%w2>G1$~PNC-zg}aqte~Yyzbk`;ePo@OE=cp+)O-+VH71dD5@}|ghB>HtIW{_ z(+PQKeeF?Q_y*2(cQ?o%0Jkft?J7^Z;@AA-Pd$C`pkcw(F(#%P$#_Bx*!d}_A}^q{ zkIlN>lxr7gEkw4`c*^P7N@|8|pFz@Rc*S0=Yoj%PWVyI6oHm>GNdUQvhx@d1# z(9OgP=N9J2FyCL`bKg~85Q^|C97YE{UU{Me`FiHyKyvshwuvr4M5&8$+cs3za)xLD zjXk@cNmEIxt#Paqof;+EJ>(N{NBo;7~YmZeqlY4yA>$s5&-KK6xT}PGO{xi6MvC zyLAhBogGE&;P+HU*91IPcJ|Nztsf`cgS!2&woJAhY`P6DgHOO^c<*o5hhZhZLH0N= z!1YW?@Bllo=QD5?!k(Rt7G(FPH^;y}qS2rz=K{P|;w)sxU-_xWSN;VC CM4A`? delta 1808 zcmZ`(K}_3L7`9`3C4=MzbyVs`$Fxx<)D`U5AQy zO@T|L9;$9aQx26@PGj9=TSKE=s$HmdsJ5{K)3nnD+&FZX?aJPB!j|IHhyURJd*A>6 z-}~Obws~%C^V}z|I^3h5c3*+<+m9aHJ@Xs*2HXO_Ry*k_u(5or0b z=D^-27MvENnv&zmCZt99GOXHQe7H$^I!ME|YB=dyl)SU;MlRkAXWFI0Qd{ufQFc{}^nfpLK6Mcyc*=mMpD-j zc_wme7q9I))&{#YJ!TBSEFaHHT+;7TWj&@=K09{2Bfv58Xx3CwQxm2X=FO<;%PWkR zwFX|U$Hu^UG_Zn@ygsDs{aZc)Aouca=0 zgVKI?+vJacZP`?_%69&mhy1;#4<1ZMVCiTf9TTN^(i`Tv5>%1%p|bmWJ)X#Aht`5* zJB>er{#*f-M49X?!wb)vHwi)=*zfJ`GzwH6fdZd|E2;oN7i2%|5la-K?O!6Bh{PK7>!50GKaU|J+P>`+4y?t^D#6`C57Z zVBTV#pLcW!>n!dP7T!H2MvI64;+1+zjuvb8IYCCDBB7auh0I)`bk(;sIC+Jg93C#L zYLddL`h+x6Q2csvIuYZSM!8k57WF5U>^RHMSRT4qkF{OW`37|W*Yj4@vvvbiK3pc9 zmCP~OQ%0M^c9rDG%0rLQwuk>S8q8+VW2ez$_xVgInOCLfjAnB-RPNeEFU<_FR^%Re IsK4(13ju0b_5c6? diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 4b2a2e5..5311917 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -138,6 +138,39 @@ export async function registerAuthRoutes(fastify) { 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) => { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) @@ -183,6 +216,30 @@ export async function registerAuthRoutes(fastify) { 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) => { const userId = request.user.sub const provider = request.params?.provider