From d60270336e05e9773ca6cdaaaaaf1d92abf4cb18 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 23:03:03 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/layout/MainLayout.tsx | 9 +-- .../me/ui/sections/AuthMethodsSection.tsx | 69 ++++++++++++++++++ .../__tests__/AuthMethodsSection.test.tsx | 6 +- client/src/shared/model/auth.ts | 5 ++ .../migration.sql | 19 +++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/prisma/schema.prisma | 15 ++++ server/src/routes/auth-session.js | 52 +++++++++++++ server/src/routes/oauth-social.js | 12 +-- 9 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 server/prisma/migrations/20260522175250_pending_email/migration.sql diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 7c8aaed..d497721 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -8,8 +8,8 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' -import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' import vkLogoSrc from '@/shared/assets/vk-logo.svg' +import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' import { ScrollToTop } from '@/shared/ui/ScrollToTop' @@ -91,12 +91,7 @@ export function MainLayout({ children }: PropsWithChildren) { color="text.secondary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }} > - + VK diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx index 79ae7f1..8df4011 100644 --- a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -9,10 +9,12 @@ import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' +import { useSearchParams } from 'react-router-dom' import { $user, changePasswordFx, fetchAuthMethodsFx, + requestEmailChangeFx, setPasswordFx, unlinkOAuthFx, type AuthMethod, @@ -77,11 +79,78 @@ export function AuthMethodsSection() { return authMethods.filter((m) => m.active).length }, [authMethods]) + const [searchParams] = useSearchParams() + const emailVerified = searchParams.get('emailVerified') + + const emailForm = useForm<{ email: string }>({ + defaultValues: { email: '' }, + }) + const [emailChangeError, setEmailChangeError] = useState(null) + const [verificationUrl, setVerificationUrl] = useState(null) + + const emailChangeMutation = useMutation({ + mutationFn: async (email: string) => { + setEmailChangeError(null) + const url = await requestEmailChangeFx(email) + return url + }, + onSuccess: (url) => setVerificationUrl(url), + onError: (err) => setEmailChangeError(err?.message || 'Не удалось сменить email'), + }) + if (!user) return null return ( + Почта + + + {emailVerified === '1' && ( + + Почта успешно подтверждена + + )} + + + {user.email} + + + {!verificationUrl && ( + + + + + )} + + {verificationUrl && ( + + + Ссылка подтверждения готова. + + + + )} + + Методы входа {fetchError && ( diff --git a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx index 06c425d..693756d 100644 --- a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx +++ b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' import { AuthMethodsSection } from '../AuthMethodsSection' @@ -15,6 +16,7 @@ vi.mock('@/shared/model/auth', () => ({ fetchAuthMethodsFx: vi.fn().mockResolvedValue([]), setPasswordFx: vi.fn(), unlinkOAuthFx: vi.fn(), + requestEmailChangeFx: vi.fn(), })) vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) @@ -29,7 +31,9 @@ function renderSection() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) return render( - + + + , ) } diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index bb3d6c4..2e91529 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -101,6 +101,11 @@ export const changePasswordFx = createEffect(async (params: { oldPassword: strin await apiClient.post('me/change-password', params) }) +export const requestEmailChangeFx = createEffect(async (email: string) => { + const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email }) + return data.verificationUrl +}) + // ----- Error stores ----- export const $updateProfileError = createErrorStore(updateProfileFx).$error diff --git a/server/prisma/migrations/20260522175250_pending_email/migration.sql b/server/prisma/migrations/20260522175250_pending_email/migration.sql new file mode 100644 index 0000000..b6dfb3c --- /dev/null +++ b/server/prisma/migrations/20260522175250_pending_email/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "PendingEmail" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PendingEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingEmail_token_key" ON "PendingEmail"("token"); + +-- CreateIndex +CREATE INDEX "PendingEmail_token_idx" ON "PendingEmail"("token"); + +-- CreateIndex +CREATE INDEX "PendingEmail_userId_idx" ON "PendingEmail"("userId"); diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index d5a37a2edc479a586a6837fc378465b2b91a5f33..873e50ecbcc654766b87cbcca7b8dbc008b9a244 100644 GIT binary patch delta 3537 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 { + const userId = request.user.sub + const rawEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : '' + + if (!rawEmail || !rawEmail.includes('@')) { + return reply.code(400).send({ error: 'Некорректная почта' }) + } + + const email = normalizeEmail(rawEmail) + + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing && existing.id !== userId) { + return reply.code(409).send({ error: 'Эта почта уже используется' }) + } + + await prisma.pendingEmail.deleteMany({ where: { userId } }) + + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + + await prisma.pendingEmail.create({ + data: { userId, email, token, expiresAt }, + }) + + return { verificationUrl: `/api/me/verify-email?token=${token}` } + }) + + fastify.get('/api/me/verify-email', async (request, reply) => { + const token = typeof request.query?.token === 'string' ? request.query.token : '' + + if (!token) { + return reply.code(400).send({ error: 'Отсутствует токен подтверждения' }) + } + + const pending = await prisma.pendingEmail.findUnique({ where: { token } }) + if (!pending || pending.expiresAt < new Date()) { + return reply.code(400).send({ error: 'Токен подтверждения недействителен или истёк' }) + } + + await prisma.user.update({ + where: { id: pending.userId }, + data: { email: pending.email }, + }) + + await prisma.pendingEmail.delete({ where: { id: pending.id } }) + + const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') + return reply.redirect(`${clientUrl}/me?emailVerified=1`) + }) } diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 5e6137e..faf74b3 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -69,7 +69,6 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken const norm = trimmed ? normalizeEmail(trimmed) : null if (linkToUserId) { - if (!norm) return null await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, }) @@ -84,13 +83,13 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken return user } - if (!norm) return null + const email = norm || `${provider}_${providerUserId}@vk.local` user = await prisma.user.create({ data: { - email: norm, - displayName: norm.split('@')[0], - avatar: await generateAvatar(norm), + email, + displayName: norm ? norm.split('@')[0] : 'Пользователь', + avatar: await generateAvatar(email), avatarStyle: 'avataaars', }, }) @@ -206,7 +205,6 @@ export async function registerOAuthSocialRoutes(fastify) { const emailSuggestion = claims?.email ?? tokenBody?.email ?? null if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id') - if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined @@ -218,8 +216,6 @@ export async function registerOAuthSocialRoutes(fastify) { linkToUserId, }) - if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') - if (linkToUserId) { const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)