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 c83aff7..cda19f9 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ 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